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.

Angular @angular/localize build-time pipeline Templates marked with the i18n attribute are extracted by ng extract-i18n into a source XLIFF, translated into per-locale XLIFF files, then the localize build inlines each into a separate locale bundle. Marked template i18n="@@id" extract-i18n messages.xlf messages.fr.xlf translated targets messages.de.xlf translated targets dist/app/fr/ inlined bundle dist/app/de/ inlined bundle
One marked source tree fans out into one inlined bundle per locale — translation happens at build time, not in the browser.

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.
Angular build-time locale artifacts @angular/localize compiles a separate bundle per locale, each with its own content hash and deploy path, and Arabic additionally carries mirrored stylesheet output. Because the strings are inlined at build time, switching locale at runtime always means a full document load rather than a state change. One build per locale, and what it costs Bundle Deploy path Runtime switch en-US own hash /en-US/ full reload de own hash /de/ full reload ar own hash + RTL css /ar/ full reload
Build-time inlining buys speed and costs runtime flexibility — the trade is made once, at project start.

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.

Where translation resolves: @angular/localize (build time) vs ngx-translate (runtime) Two columns. On the left, @angular/localize takes one marked source tree, runs ng build --localize, and emits separate inlined bundles for /fr/, /de/ and /en/; the browser loads one prebuilt bundle with zero runtime lookup and no untranslated flash, but adding a locale means a rebuild and redeploy. On the right, ngx-translate ships one bundle plus fr.json and de.json dictionaries, resolves each key in the browser via TranslateService, and swaps dictionaries with no reload for an instant switch — at the cost of a client-side lookup and possible untranslated flash, while adding a locale is just shipping a JSON file. @angular/localize build time · N bundles One marked source tree ng build --localize /fr/ /de/ /en/ browser loads one bundle Zero runtime lookup · no flash Add a locale → rebuild + redeploy ngx-translate runtime · 1 bundle bundle + fr.json / de.json translate.get('key') Key resolved in browser, per string swap dictionary, no reload Instant switch · lookup + flash risk Add a locale → ship a JSON file
The axis that decides the library: @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 wrong baseHref breaks relative asset URLs. Set baseHref per locale, or pass --base-href at build time.
  • English silently shown for an untranslated string. Default i18nMissingTranslation is warning, so builds pass. Set it to error in CI.
  • Locale code mismatch (fr-FR vs fr). The id in angular.json must match the file’s target locale exactly, or the file is ignored without error.
  • $localize metadata 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.

Part of Frontend Framework i18n & Component Routing.