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.
@angular/localize resolves every string at compile time into one bundle per locale, while ngx-translate resolves keys in the browser from a shared dictionary.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.
Deploying one build per locale
Because @angular/localize inlines translations at build time, a multilingual Angular app is not one artifact but several, and the deployment story is a routing story. Each locale build is emitted into its own output directory named after the locale, with its own index.html, its own bundle hashes and — for right-to-left locales — its own stylesheet ordering. Nothing at runtime can turn one into another.
That leaves two workable serving strategies. The first is a path prefix per locale, where a reverse proxy or CDN maps /de/ to the German build’s directory and rewrites the base href accordingly. This is the strategy that matches how readers expect URLs to behave and the one that lets a shared link carry its language. The second is a domain or subdomain per locale, which suits organizations whose locales are also separate legal entities with separate content, and which sidesteps base-href juggling entirely at the cost of separate certificates and analytics properties.
Whichever you choose, the base href has to match the serving path or every relative asset request will 404 in exactly one locale — usually the one nobody tests. Set it per configuration in angular.json rather than patching index.html after the build, and make a smoke test that loads each locale’s entry point part of the deploy pipeline. A build-time i18n system fails at deploy time, not at runtime, so the deploy is where the check belongs.
Choosing between build-time and runtime translation
Angular’s built-in system is deliberately build-time, and that choice has consequences worth stating plainly before a project commits to it.
Build-time inlining is faster at runtime — there is no lookup, no catalogue in memory, no parsing — and it makes untranslated strings a build failure rather than a production surprise. It costs you runtime locale switching: changing language means loading a different document, which is fine for content sites and awkward for an authenticated application where a user expects a language menu to take effect immediately.
Runtime translation, as provided by libraries such as ngx-translate or transloco, inverts both properties. The catalogue is fetched and held in memory, language switches are instantaneous and stateful, and the cost is a lookup on every binding plus the risk that a missing key surfaces to a user rather than to CI. Teams that need in-app switching, or that ship strings from a CMS after the build, generally end up here.
The two are not mutually exclusive. A common arrangement uses @angular/localize for the static application shell — navigation, legal copy, error pages, everything known at build time — and a runtime library for the dynamic subset that genuinely arrives later. The important part is to make the split deliberate and documented, because a codebase where both systems translate the same string is one where nobody can predict which one wins.
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.