Angular Localization Module Setup
Angular ships compile-time localization through @angular/localize, which marks strings with the i18n attribute, extracts them to XLIFF, and bakes one bundle per locale at build time — so a missing <target> surfaces as the English source silently rendering instead of an error. This guide walks the full pipeline: marking, ng extract-i18n, per-locale builds, the $localize tagged template, and the runtime-versus-build-time trade-off that defines how Angular differs from ngx-translate.
Unlike runtime libraries that ship a JSON dictionary and resolve keys in the browser, @angular/localize resolves every translatable string during ahead-of-time (AOT) compilation. The output is N physically separate applications — dist/app/fr/, dist/app/de/ — each with translations already inlined and the source-locale strings tree-shaken out. That eliminates client-side lookup cost and flash-of-untranslated-content, but it means adding a language is a rebuild, not a config push.
Prerequisites
Concept & spec: where @angular/localize sits
@angular/localize implements message localization against the XLIFF 1.2 and XLIFF 2.0 OASIS standards (--format=xlf and --format=xlf2 respectively), with optional XMB/XTB for Google-internal pipelines. Each translatable unit carries a message ID — either a content-hash Angular derives automatically, or a stable custom ID you assign with the @@ syntax. Build-time inlining is what distinguishes it from runtime dictionaries; this places it firmly on the build-time side of the same architectural spectrum covered across Frontend Framework i18n & Component Routing.
The unit of translation is the i18n attribute, not a key string. You annotate the DOM node whose text must change. Angular extracts the rendered content (including ICU expressions and interpolations as placeholders) into a <trans-unit>. Because IDs are derived from source content by default, editing the English copy invalidates the old translation — which is exactly the failure mode dissected in Angular localize missing translation IDs.
Step-by-step implementation
1. Mark template strings with the i18n attribute
Add i18n to any element whose text content is translatable. Supply a stable custom ID with @@, plus optional meaning and description (meaning|description) to disambiguate identical source strings.
<!-- meaning|description@@customId -->
<h1 i18n="site header|Main landing headline@@home.hero.title">
Ship in every language
</h1>
<button i18n="@@home.cta.signup">Sign up</button>
<!-- Attributes are marked with i18n-<attr> -->
<img [src]="logo" i18n-alt="@@home.logo.alt" alt="Company logo" />
Custom IDs decouple the translation from the exact source wording: copy edits no longer orphan an existing <target>.
2. Mark dynamic strings with $localize
For strings in TypeScript (toasts, validators, computed labels) use the $localize tagged template. It is a global injected by the polyfill, so no import is needed, but the metadata block must be the first interpolation.
// notification.service.ts
const msg = $localize`:@@toast.saved:Your changes were saved`;
// With interpolation and a placeholder name:
const greeting = $localize`:@@greeting:Hello ${userName}:name:!`;
The :name: suffix names the placeholder so translators see {name} rather than an anonymous {$INTERPOLATION}.
3. Handle plurals and selects with ICU
Inline ICU MessageFormat directly in the template; Angular extracts the whole expression as one unit. Pluralization categories follow CLDR rules per locale — see Pluralization Rules Across Languages for why fr and ru need different categories than en.
<span i18n="@@cart.count">{itemCount, plural,
=0 {Your cart is empty}
one {# item in cart}
other {# items in cart}
}</span>
4. Extract to XLIFF with ng extract-i18n
Run extraction to produce the source-locale file. Commit it; translators (or your TMS) produce messages.<locale>.xlf siblings with populated <target> elements.
ng extract-i18n --format=xlf2 --output-path=src/locale --out-file=messages.xlf
Re-run on every change. New units appear without <target>; the localize build will warn and fall back to source for those.
5. Map locales in angular.json
Declare the source locale and each target’s translation file plus its baseHref. The baseHref is what keeps static asset paths correct in the per-locale output directory.
{
"projects": {
"app": {
"i18n": {
"sourceLocale": "en-US",
"locales": {
"fr": { "translation": "src/locale/messages.fr.xlf", "baseHref": "/fr/" },
"de": { "translation": "src/locale/messages.de.xlf", "baseHref": "/de/" }
}
},
"architect": {
"build": { "options": { "localize": true } }
}
}
}
}
6. Build per-locale bundles
localize: true builds every declared locale into its own directory; pass an array to build a subset. Each output is a complete, standalone app with translations already inlined.
# All locales (one folder each under dist/app/):
ng build --localize
# Subset during development:
ng build --configuration=development --localize=fr
Configuration reference
| Option | Type | Description / default |
|---|---|---|
sourceLocale |
string |
Locale the source strings are authored in. Default en-US. Changing it re-IDs auto-generated messages. |
i18n.locales.<id>.translation |
string | string[] |
Path(s) to the XLIFF/XMB file(s) for that locale. |
i18n.locales.<id>.baseHref |
string |
Per-locale base href injected into index.html. Set to "" to suppress; default is /<id>/. |
localize (build option) |
boolean | string[] |
true builds all locales; an array builds a subset; false builds source only. |
--format (extract) |
xlf | xlf2 | xmb | json | arb |
Output format for extract-i18n. xlf2 = XLIFF 2.0. |
i18nMissingTranslation |
error | warning | ignore |
Build behavior when a <target> is absent. Default warning. Set error in CI. |
i18nDuplicateTranslation |
error | warning | ignore |
Behavior on duplicate IDs across files. |
Framework variants
@angular/localize vs ngx-translate (runtime)
@angular/localize is build-time: zero runtime lookup, N bundles, rebuild to add a language. ngx-translate (and @ngx-translate/core) loads JSON dictionaries at runtime via a TranslateService, so one bundle serves all locales and language switching is instant — at the cost of a client-side resolution step and possible untranslated flashes. Choose build-time for SEO-critical, content-stable sites; choose runtime when users toggle locale in-session.
Standalone / signals (Angular 17+)
There is no localize NgModule to import — the polyfill import '@angular/localize/init'; in main.ts (added by ng add) is all that is required. Marking works identically in standalone components. For reactive locale-dependent state, expose a signal or BehaviorSubject from a root service and consume it via the async pipe.
Server-side rendering
With @angular/ssr, each locale’s server bundle is built separately and served from its baseHref. Route requests to the correct locale bundle at the edge or reverse proxy by URL prefix; this mirrors the prefix routing strategy in Next.js i18n Routing Setup and the locale resolution discussed in Locale Negotiation Strategies.
Bridging gettext / XLIFF formats
Teams whose TMS speaks gettext .po but whose Angular build consumes XLIFF need a conversion step; the round-trip rules and lossy-field pitfalls live in PO / XLIFF Format Bridging.
Reactive locale state pattern
When non-template logic must react to the active locale, propagate it through DI rather than reading globals. Manage subscriptions with takeUntilDestroyed() to avoid leaks in long-lived services.
// locale.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class LocaleService {
private locale$ = new BehaviorSubject<string>('en');
readonly currentLocale$ = this.locale$.asObservable();
setLocale(lang: string): void { this.locale$.next(lang); }
}
The composition-API equivalent of this reactive pattern is contrasted in the Vue I18n Composition API Guide.
Verification
Gate merges in CI by treating a missing translation as a hard failure and building every locale. The extraction must also produce no uncommitted diff — that proves no new unmarked or unextracted strings slipped in.
# .github/workflows/i18n-angular.yml
name: Angular i18n Validation
on: [pull_request]
jobs:
validate-i18n:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- name: Re-extract and assert no drift
run: |
npx ng extract-i18n --format=xlf2 --output-path=src/locale --out-file=messages.xlf
git diff --exit-code src/locale/messages.xlf
- name: Build all locales (missing = error)
run: npx ng build --localize --configuration=production
Expected output: git diff --exit-code returns 0 when the source XLIFF is current, and the build aborts non-zero if any <target> is missing while i18nMissingTranslation is set to error.
Common pitfalls
- Source string edited, translation orphaned. Auto-generated content-hash IDs change when wording changes. Assign stable
@@custom IDs, or follow the diagnosis in Angular localize missing translation IDs. - Asset 404s in
dist/app/fr/. A missing or wrongbaseHrefbreaks relative asset URLs. SetbaseHrefper locale, or pass--base-hrefat build time. - English silently shown for an untranslated string. Default
i18nMissingTranslationiswarning, so builds pass. Set it toerrorin CI. - Locale code mismatch (
fr-FRvsfr). The id inangular.jsonmust match the file’s target locale exactly, or the file is ignored without error. $localizemetadata not first. The:@@id:block must be the first interpolation in the tagged template, or extraction misreads the unit.- Adding a language expecting a config push. Build-time inlining means a new locale requires a full rebuild and redeploy — not a runtime dictionary upload.
FAQ
Do I use @angular/localize or ngx-translate?
Use @angular/localize when locales are stable, SEO matters, and you can afford per-locale builds — it has zero runtime translation cost. Use ngx-translate when users switch language in-session or you need a single bundle serving all locales, accepting a client-side lookup. They solve the same problem at opposite ends of the build-time/runtime spectrum.
Why is my translated string still showing in English?
By default i18nMissingTranslation is warning, so a <trans-unit> with no <target> falls back to the source string and the build succeeds. Re-run ng extract-i18n, populate the <target>, and set i18nMissingTranslation to error in CI so the gap fails the build instead of shipping silently.
How do I add interpolated values to a $localize string?
Interpolate normally and name each placeholder with a trailing :name: token: $localize`:@@id:Hello ${user}:name:!`. The name is what translators see, so anonymous ${...} placeholders should be avoided in any string with more than one variable.
Should I let Angular auto-generate IDs or assign custom ones?
Assign custom IDs with @@ for anything that ships. Auto-generated IDs are content hashes, so any copy edit changes the ID and orphans the existing translation. Custom IDs make the translation survive wording changes and keep XLIFF diffs reviewable.
Can I switch locale at runtime with @angular/localize?
Not within a single loaded bundle — each locale is a separately built application. “Switching” means navigating to the other locale’s baseHref (e.g. /fr/), which loads that locale’s bundle. For true in-session toggling without reload, a runtime library like ngx-translate is the right tool.
Related
- Angular localize missing translation IDs — why content-hash IDs orphan translations and how to fix it.
- PO / XLIFF Format Bridging — converting between gettext
.poand the XLIFF your Angular build consumes. - Next.js i18n Routing Setup — prefix-based locale routing to compare with Angular’s per-locale
baseHref. - Vue I18n Composition API Guide — the runtime, composition-based counterpart to Angular’s build-time model.
- Pluralization Rules Across Languages — CLDR plural categories that drive Angular’s inline ICU expressions.