Files
apf_portal/docs/decisions/0019-internationalisation-angular-localize.md
T
Julien Gautier 161e2ecb1c
CI / scan (pull_request) Successful in 2m26s
CI / check (pull_request) Successful in 2m38s
CI / commits (pull_request) Successful in 2m44s
CI / a11y (pull_request) Successful in 1m17s
CI / perf (pull_request) Successful in 2m17s
docs(decisions): add ADR-0019 i18n + ADR-0020 portal-admin
ADR-0019 picks `@angular/localize` in build-time mode with two locales
(`fr` default, `en` source). URLs are always prefixed (`/fr/...`,
`/en/...`); `/` smart-redirects via cookie → Accept-Language → fr.
UI strings live in XLIFF; editorial content (CMS-served) is locked to
the admin-app pipeline. The locale switcher in the footer writes a
`__Host-portal_locale` cookie and hard-refreshes to the matching
bundle. The `@angular/localize` runtime mode, ngx-translate, and
transloco alternatives are recorded as considered-and-rejected.

ADR-0020 splits portal administration into a dedicated Angular SPA
(`portal-admin`) sharing the existing `portal-bff` via `/api/admin/*`
routes guarded by an Entra `admin` role plus fresh-MFA at entry. Same
identity / sessions / audit / observability primitives reused, no
infrastructure duplication. v1 ships four modules: CMS for static
multilingual pages, sidebar menu management (activating the
`requiredPermissions` field already on `MenuItem`), read-only user
list, and an audit log viewer. Bundle budget relaxed to 500 KB gzip
(vs 300 KB on `portal-shell`); same a11y + dark-mode baseline.

Together they answer the two questions raised after the footer
chantier: how the multilingual story works, and where the admin
surface lives. Implementation lands across follow-up feature PRs;
this commit is documentation only.

CLAUDE.md picks up summary entries for both decisions and bumps the
ADR coverage line to 0020.
2026-05-11 11:19:30 +02:00

17 KiB

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-05-11 R&D Lead
frontend
accessibility
performance
process

Internationalisation — @angular/localize, build-time per-locale bundles, /fr + /en path-based routing

Context and Problem Statement

The portal addresses a primarily French audience (APF France handicap, fédération française) but must also serve English content for international stakeholders, internal staff who prefer English, and the broader accessibility audit ecosystem (WCAG / EN 301 549). The current state ships UI strings hard-coded in English (project rule: "All code, identifiers, comments, ... written in English"), duplicates the accessibility page across two routes (/accessibility and /accessibilite), and exposes both language labels in the footer. That works as a placeholder; it does not scale.

Two questions need a recorded answer before any new feature wires a string into a template:

  1. Which i18n library / strategy?@angular/localize (Angular-canonical, build-time), the runtime variant of the same, or a community alternative (@ngx-translate, transloco).
  2. How is the locale carried in URLs and across navigations? — path prefix (/fr/dashboard), query parameter (?lang=fr), subdomain (fr.portal.apf.fr), or no URL signal (locale held in a cookie only).

This ADR settles both, plus the related questions of source locale, default locale, locale resolution order, and how the locale switcher in the footer interacts with the build-time bundles.

A related concern — editorial content localisation (CMS-managed pages, news, etc.) — is out of scope for this ADR. Editorial copy is fetched from the BFF already localised per the active locale; that pipeline belongs to ADR-0020 (the admin application). This ADR is about UI strings owned by developers: button labels, menu titles, error messages, ARIA labels, format strings.

Decision Drivers

  • First-party, recognised, stable — per the project tech bar, default to Angular's own i18n module unless an exception is justified. @angular/localize is shipped by the Angular team, tracks Angular versions, will not be orphaned.
  • Performance — ADR-0017 sets Core Web Vitals + Lighthouse ≥ 90 + initial bundle ≤ 300 KB gzip. A build-time strategy (one bundle per locale) avoids shipping translations to the wrong audience and avoids the runtime cost of resolving every $localize token on first paint.
  • Accessibility<html lang> must match the served content (WCAG 3.1.1 "Language of Page"); screen readers and translation tooling rely on it. A URL prefix per locale makes this trivial; a query-param strategy makes the server forget which locale to declare.
  • SEO & shareability — path-based locale URLs are the documented best practice (Google Search Central, W3C i18n WG). /fr/... and /en/... are crawled, indexed, and shared without ambiguity.
  • No bricolage — a runtime locale switcher that hot-swaps strings without a hard reload is appealing but introduces complexity (every text node observes the locale signal); we accept a hard refresh on switch for v1 because it costs no implementation surface and produces the smaller artefact.

Considered Options

i18n library

  • @angular/localize — build-time mode (chosen). Strings marked in templates (i18n attribute, <ng-container i18n>...) and in code ($localize). Translation files in XLIFF 1.2 (.xlf). One application bundle per locale at build time. Locale-aware tooling (extract-i18n, nx build --localize) shipped with the framework.
  • @angular/localize — runtime mode ($localize only, no build-time embed). Single bundle ships all locales; the locale is selected at runtime via loadTranslations(). Smaller deploy artefact count, higher runtime cost and bigger initial JS payload.
  • @ngx-translate/core. Community library (organisation-maintained since 2024). Runtime translations from JSON files. Mature but smaller team than Angular Core, occasional Angular-version lag.
  • transloco (@jsverse/transloco). Active community library with a modern, Signals-friendly API and lazy translation file loading. Less mainstream than @angular/localize.
  • Roll our own (Signals + a tr() function over a JSON map). Rejected on the tech bar — bricolage.

URL strategy

  • Path-based with default locale at root: /dashboard (serves fr), /en/dashboard. Idiomatic for sites with one strongly dominant locale. Asymmetric: removing the prefix means "default locale".
  • Path-based, always-prefixed: /fr/dashboard, /en/dashboard; / redirects to /fr/ (chosen). Symmetric. Every URL carries an explicit locale signal. The redirect at / uses a small smart-resolver (cookie → Accept-Languagefr fallback).
  • Query parameter: /dashboard?lang=fr. Single canonical path. Fragile (a user trimming the query lands on whatever the default is), worse for SEO, and the locale signal is invisible in the address bar at a glance.
  • Subdomain: fr.portal.apf.fr, en.portal.apf.fr. Highest isolation. Overkill for two locales, complicates __Host- cookie scoping (ADR-0009 / ADR-0010), requires more TLS certificates.

Source locale

  • English (chosen). The source code holds English text in i18n attributes; the FR translation is a target. Matches the project English-only rule (CLAUDE.md), matches what translators expect.
  • French. Source is French; English is a target. Would conflict with the project English-only rule for code artefacts.

Default locale (served at /)

  • French (chosen). APF audience is overwhelmingly French; the redirect at / lands users in the locale most of them want first.
  • English. Lingua franca but mismatched with the audience.

Locale switcher mechanism

  • Footer link / button that posts the chosen locale to the BFF; the BFF sets __Host-portal_locale cookie; client hard-refreshes to the same path under the new prefix (chosen). Honest about the cost (full reload to swap bundles), the cookie persists the choice across visits, and the resolver at / uses the cookie next time.
  • Pure client-side path swap (router.navigateByUrl('/en' + currentPath)). Equivalent for the user but loses the cookie persistence — next visit the resolver does not know the preference.
  • Hot-swap translations at runtime via loadTranslations(). Only feasible with the runtime mode of @angular/localize (rejected above) or transloco / @ngx-translate. Smoother UX, much higher complexity.

Decision Outcome

Library — @angular/localize, build-time per-locale bundles

  • Strings are marked with the Angular i18n template attribute (<button i18n="@@dashboard.title">Dashboard</button>) for templates, and $localize tagged template strings for code (throw new Error($localize\:@@auth.expired:Session has expired`)). Every key gets an explicit @@id` — auto-generated IDs are brittle (they change when the surrounding text changes).
  • Translation files live at apps/portal-shell/src/locale/messages.fr.xlf. Source language is en, target language is fr. The English bundle is the source — no translation file, the source strings ship as-is. The extraction target (nx extract-i18n portal-shell) produces messages.xlf (source-only); we maintain messages.fr.xlf by hand-merging extractor output into the existing translations.
  • Build target gains a localize configuration: nx build portal-shell --localize produces dist/apps/portal-shell/{fr,en}/... in one pass. The dev server (nx serve portal-shell) defaults to French; --configuration=en flips to English.
  • Production deploy serves both locale folders behind one origin; the reverse proxy (or the BFF for SPA pass-through) routes /fr/* and /en/* to the matching folder.

URL strategy — path-based, always prefixed, / smart-redirects

  • Every route in the app sits under /fr/... or /en/.... The Angular routes themselves are locale-agnostic (/dashboard, /accessibility-statement, ...); the locale prefix is injected by the build-time baseHref plus the SPA's provideRouter({ baseHref: '/fr/' }) (set per the active build locale).
  • The bare path / redirects via a small smart resolver, executed at the reverse-proxy / BFF level:
    1. If the __Host-portal_locale cookie is set and matches a supported locale, redirect to that prefix.
    2. Else, parse Accept-Language and pick the highest-q match among {fr, en}.
    3. Else, redirect to /fr/.
  • Direct paths missing a locale prefix (e.g. someone shares /dashboard) hit the same resolver and get redirected to the prefixed equivalent under the resolved locale.
  • <html lang="fr"> (or en) is set at build time from the active locale; no JavaScript fiddling at runtime.

Source = en, default served = fr

  • The source locale is English. All i18n attribute texts in templates, all $localize template literal payloads, are English. This matches the project English-only rule.
  • The locale served at the root URL is French. The redirect lands the user there unless their cookie or Accept-Language says otherwise.
  • The footer (per the previous chantier) gets a small locale switcher next to the accessibility links. UI: a [cdkMenuTriggerFor] button labelled with the active locale's name, opening a menu with the two options ("Français", "English"). The same accessible CDK menu pattern as the theme switcher (ADR-0016 derivative).
  • Clicking an option POSTs { locale: 'fr' | 'en' } to /api/preferences/locale. The BFF sets __Host-portal_locale (Secure, HttpOnly, SameSite=Lax, scoped to /) and returns 204. The client then window.location.assign('/en' + currentPathWithoutLocalePrefix) (or the FR equivalent) — a hard refresh that boots the right bundle.
  • We accept the hard refresh as the v1 cost. Runtime hot-swap is a v2 ADR if user research surfaces friction; for now the gain (no runtime locale state, no per-text observer) is worth the per-switch reload.

Routing migration

  • The current /accessibility and /accessibilite duplicate routes collapse to a single localised route. The Angular route is '/accessibility-statement'; the displayed URL is /fr/declaration-d-accessibilite (translated via Angular's i18n route paths feature) or /en/accessibility-statement. Both old routes 301-redirect to the localised version.
  • All future routes use a single Angular path; the build pipeline emits the locale-specific URL per the i18n route-path translation file.

Confirmation

Wired across a sequence of PRs:

  1. Install @angular/localize, add the localize polyfill to polyfills.ts, configure the build target (this ADR's accompanying PR or the next one).
  2. First sweep: mark every existing UI string in portal-shell with an i18n attribute + explicit @@id; run extraction; produce messages.fr.xlf with translations of the current copy (≤ 30 strings today).
  3. Locale switcher in the footer + BFF route /api/preferences/locale + cookie + smart redirect at /.
  4. Collapse /accessibility + /accessibilite into the single localised route, with 301s.
  5. Lint rule (@angular-eslint/template/no-positive-tabindex analogue, custom) to flag template strings without an i18n attribute — wired only after sweep #2 to avoid mass lint debt.

CI gate: the build script pnpm exec nx build portal-shell --localize is added to ci:check. If a string is missing a translation in messages.fr.xlf, the build fails with the missing-translation list. This catches "I added a label and forgot to translate it" at the PR stage rather than at deploy.

Lighthouse bench: the localised production builds are exercised by the existing ci:perf Lighthouse CI (ADR-0017) on /fr/ (default) and /en/. Both must stay ≥ 90 Performance.

Consequences

  • Good, because the i18n primitive is first-party, recognised, and aligned with the project tech bar. Future Angular upgrades carry it.
  • Good, because build-time bundles ship only the strings the user actually sees — smallest possible payload per visit, best Core Web Vitals.
  • Good, because the URL strategy makes the locale signal explicit (SEO, sharing, accessibility — <html lang> is correct by construction).
  • Good, because the source locale is English — matches the project rule and the global i18n convention (translators translate from English).
  • Good, because the accessibility statement collapses to one route — a single source of truth instead of two manually-kept-in-sync templates.
  • Bad, because every build now produces N bundles (N = locale count). Bounded — we ship two for the foreseeable future. The CI build wall-time grows linearly with locale count; acceptable while N ≤ 4.
  • Bad, because adding a new locale requires a code-level change (new translation file, build target, deploy route) rather than a configuration toggle. Acceptable trade-off for the runtime perf gain. Editorial content (CMS-driven, ADR-0020) does not have this constraint — adding a CMS locale is a backend operation.
  • Bad, because the locale switch costs a hard refresh. Mitigated by the rarity of the action (users typically pick a locale on first visit and stay there) and by the SPA's fast cold-start budget (initial bundle ≤ 300 KB gzip, LCP ≤ 2.5 s).
  • Neutral, because translators see XLIFF 1.2 — the industry standard for CAT tools (memoQ, Trados, Crowdin, Lokalise). Good for handoff to a professional translator if needed; less ergonomic than JSON for a developer-managed file. Acceptable: the file is small.

Confirmation (continued)

A future ADR may revisit this decision if:

  • Runtime locale hot-swap becomes a usability requirement that user research confirms is worth the implementation cost.
  • A third locale (Spanish, German, Arabic) is requested — at that point the build-time-per-locale cost grows enough to reconsider the runtime alternatives, AND RTL support (Arabic) needs first-class treatment.
  • The number of UI strings grows to the point where hand-maintained XLF becomes a maintenance burden — at which point we plug in a translation management platform (Crowdin / Lokalise) over the same XLF artefacts; no ADR change.

Pros and Cons of the Options

Library

@angular/localize build-time (chosen)

  • Good, because first-party, supported as long as Angular itself is.
  • Good, because zero runtime cost — strings are literals in the produced bundle.
  • Good, because tooling (extract-i18n) is integrated into the Angular CLI.
  • Bad, because one bundle per locale (N artefacts). Bounded.
  • Bad, because adding a locale requires a rebuild rather than a config change. Bounded for our locale count.

@angular/localize runtime

  • Good, because single artefact across locales — single deploy.
  • Good, because runtime locale switch is possible without a page reload.
  • Bad, because all translations ship to every user — bigger initial payload, worse mobile-3G LCP.
  • Bad, because the $localize runtime resolver runs on every translated node — measurable on first render of a complex view.

@ngx-translate/core

  • Good, because lazy-loaded JSON files (load on demand per feature module).
  • Good, because runtime switching with no reload.
  • Bad, because community-maintained — Angular version compatibility has occasionally lagged by weeks.
  • Bad, because non-canonical for Angular — every contributor must learn its API on top of Angular's.

transloco

  • Good, because modern, Signals-friendly, actively maintained.
  • Good, because lazy-loadable, scope-based.
  • Bad, because community library — tech-bar threshold is "recognised + battle-tested"; transloco is recognised but not at the Angular-team level.
  • Bad, because we'd carry a non-canonical i18n abstraction across the codebase forever.

URL strategy

Path-based, always prefixed (chosen)

  • Good, because explicit, SEO-canonical, easy to reason about.
  • Good, because <html lang> is correct by construction.
  • Good, because cacheable per locale at the proxy / CDN level.
  • Bad, because every URL is slightly longer.

Path-based with default at root

  • Good, because shorter URLs for the dominant locale.
  • Bad, because asymmetric — the absence of a prefix is itself a signal, which is harder to teach contributors than "every URL has a locale prefix".
  • Bad, because URL surgery on locale switch is more complex (sometimes strip, sometimes add a prefix).

Query parameter

  • Bad, because trivial to lose; fragile.
  • Bad, because SEO crawlers index multiple URLs as one with parameter variants.

Subdomain

  • Good, because hard isolation per locale.
  • Bad, because __Host- cookie scoping breaks (the cookie is host-bound). Sessions ADR-0010 explicitly uses __Host- for the cookie security guarantees.
  • Bad, because every locale needs a TLS certificate.

More Information