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.
17 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | ||||
|---|---|---|---|---|---|---|---|
| accepted | 2026-05-11 | R&D Lead |
|
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:
- Which i18n library / strategy? —
@angular/localize(Angular-canonical, build-time), the runtime variant of the same, or a community alternative (@ngx-translate,transloco). - 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/localizeis 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
$localizetoken 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 (i18nattribute,<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 ($localizeonly, no build-time embed). Single bundle ships all locales; the locale is selected at runtime vialoadTranslations(). 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(servesfr),/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-Language→frfallback). - 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
i18nattributes; 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_localecookie; 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) ortransloco/@ngx-translate. Smoother UX, much higher complexity.
Decision Outcome
Library — @angular/localize, build-time per-locale bundles
- Strings are marked with the Angular
i18ntemplate attribute (<button i18n="@@dashboard.title">Dashboard</button>) for templates, and$localizetagged 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 isen, target language isfr. The English bundle is the source — no translation file, the source strings ship as-is. The extraction target (nx extract-i18n portal-shell) producesmessages.xlf(source-only); we maintainmessages.fr.xlfby hand-merging extractor output into the existing translations. - Build target gains a
localizeconfiguration:nx build portal-shell --localizeproducesdist/apps/portal-shell/{fr,en}/...in one pass. The dev server (nx serve portal-shell) defaults to French;--configuration=enflips 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-timebaseHrefplus the SPA'sprovideRouter({ baseHref: '/fr/' })(set per the active build locale). - The bare path
/redirects via a small smart resolver, executed at the reverse-proxy / BFF level:- If the
__Host-portal_localecookie is set and matches a supported locale, redirect to that prefix. - Else, parse
Accept-Languageand pick the highest-q match among{fr, en}. - Else, redirect to
/fr/.
- If the
- 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">(oren) 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
i18nattribute texts in templates, all$localizetemplate 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-Languagesays otherwise.
Locale switcher — footer dropdown, BFF cookie, hard refresh
- 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 thenwindow.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
/accessibilityand/accessibiliteduplicate 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:
- Install
@angular/localize, add thelocalizepolyfill topolyfills.ts, configure the build target (this ADR's accompanying PR or the next one). - First sweep: mark every existing UI string in
portal-shellwith ani18nattribute + explicit@@id; run extraction; producemessages.fr.xlfwith translations of the current copy (≤ 30 strings today). - Locale switcher in the footer + BFF route
/api/preferences/locale+ cookie + smart redirect at/. - Collapse
/accessibility+/accessibiliteinto the single localised route, with 301s. - Lint rule (
@angular-eslint/template/no-positive-tabindexanalogue, custom) to flag template strings without ani18nattribute — 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
$localizeruntime 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
- Angular i18n docs: https://angular.dev/guide/i18n
- Google Search Central — Multi-regional and multilingual sites: https://developers.google.com/search/docs/specialty/international
- W3C i18n — Choosing a language strategy: https://www.w3.org/International/questions/qa-when-xmllang.en
- Related ADRs: ADR-0004 (frontend stack), ADR-0016 (WCAG 3.1.1 Language of Page), ADR-0017 (Lighthouse gate), ADR-0020 (admin app — editorial content localisation).