Skip to content

03 · Internationalization

Internationalization (i18n) means preparing an app so it can be translated and formatted for different languages and regions; localization (l10n) is doing it for a particular locale. Angular's built-in approach, @angular/localize, works at build time: you mark text in templates and code, extract it into translation files, and the build produces one fully translated copy of the app per locale. There's no runtime translation lookup and no flash of untranslated text.

Everything below was run on a copy of the Level 1 reading-list app with a French translation.

1. Add the package

ng add @angular/localize
UPDATE src/main.ts (267 bytes)
UPDATE tsconfig.app.json (429 bytes)
UPDATE tsconfig.spec.json (436 bytes)
UPDATE angular.json (2430 bytes)

It added /// <reference types="@angular/localize" /> to main.ts, the @angular/localize types to both tsconfigs, and "polyfills": ["@angular/localize/init"] to the build options — the tiny runtime $localize function.

2. Mark text

src/app/greeting.ts
import { Component, signal } from '@angular/core';
import { DatePipe, CurrencyPipe } from '@angular/common';

@Component({
  selector: 'app-greeting',
  imports: [DatePipe, CurrencyPipe],
  template: `
    <h2 i18n="Page heading on the reading list|@@readingListHeading">My reading list</h2>
    <p i18n="@@booksCount">{count(), plural, =0 {No books yet} =1 {One book} other {{{ count() }} books}}</p>
    <input i18n-placeholder="@@searchPlaceholder" placeholder="Search by title" />
    <p>{{ today | date: 'fullDate' : 'UTC' }} · {{ 1234.5 | currency: 'EUR' }}</p>
    <button type="button" (click)="count.update((n) => n + 1)" i18n="@@addBook">Add a book</button>
    <p id="status">{{ status() }}</p>
  `,
})
export class Greeting {
  protected readonly count = signal(0);
  protected readonly today = new Date(Date.UTC(2026, 8, 29));
  protected readonly status = signal($localize`:@@savedStatus:Saved ${3}:count: changes`);
}
  • i18n marks an element's text. Its value is optional metadata: meaning|description@@customId. The description helps translators; the custom ID (@@readingListHeading) keeps the ID stable when you edit the English text. Without one, the ID is a hash of the text and meaning, and every wording change creates a "new" message that must be re-translated.
  • i18n-placeholder (or i18n-title, i18n-alt, …) marks an attribute.
  • ICU expressions handle plurals and selections inside the message: {count(), plural, =0 {…} =1 {…} other {…}}. Other languages have other plural categories (few, many), and translators can add them.
  • $localize is the tagged template for text in TypeScript. :@@id: sets the ID, and ${value}:name: names a placeholder.

3. Extract

ng extract-i18n --output-path src/locale
Extraction Complete. (Messages: 5)

The generated src/locale/messages.xlf (XLIFF 1.2 by default; --format offers xlf2, xmb, json and arb) contained entries like:

<trans-unit id="readingListHeading" datatype="html">
  <source>My reading list</source>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/greeting.ts</context>
    <context context-type="linenumber">8,9</context>
  </context-group>
  <note priority="1" from="meaning">Page heading on the reading list</note>
</trans-unit>
<trans-unit id="booksCount" datatype="html">
  <source>{VAR_PLURAL, plural, =0 {No books yet} =1 {One book} other {<x id="INTERPOLATION"/> books}}</source>
  …
</trans-unit>
<trans-unit id="savedStatus" datatype="html">
  <source>Saved <x id="count" equiv-text="3"/> changes</source>
  …
</trans-unit>

Placeholders appear as <x id="…"/> tags; translators move them but must not change them.

4. Translate

Copy the file to messages.fr.xlf, set target-language="fr", and add a <target> to each unit (translation tools and services work with XLIFF directly):

<trans-unit id="readingListHeading" datatype="html">
  <source>My reading list</source>
  <target>Ma liste de lecture</target>
</trans-unit>
<trans-unit id="booksCount" datatype="html">
  <source>{VAR_PLURAL, plural, =0 {No books yet} =1 {One book} other {<x id="INTERPOLATION"/> books}}</source>
  <target>{VAR_PLURAL, plural, =0 {Aucun livre} =1 {Un livre} other {<x id="INTERPOLATION"/> livres}}</target>
</trans-unit>
<trans-unit id="savedStatus" datatype="html">
  <source>Saved <x id="count" equiv-text="3"/> changes</source>
  <target><x id="count" equiv-text="3"/> modifications enregistrées</target>
</trans-unit>

(The French word order differs — the placeholder moved to the front. That's why messages must be whole sentences, not concatenated fragments.)

5. Configure and build

angular.json (project level)
"i18n": {
  "sourceLocale": "en-US",
  "locales": { "fr": { "translation": "src/locale/messages.fr.xlf" } }
}

and "localize": true in the build options. ng build then produced one folder per locale:

dist/reading-list/browser/en-US/index.html   <html lang="en-US">   <base href="/en-US/">
dist/reading-list/browser/fr/index.html      <html lang="fr">      <base href="/fr/">

Rendered in Chromium, clicking "Add a book" twice:

en-US  My reading list | No books yet | Tuesday, September 29, 2026 · €1,234.50 | Add a book | Saved 3 changes
       after 1 click: One book       after 2 clicks: 2 books
fr     Ma liste de lecture | Aucun livre | mardi 29 septembre 2026 · 1 234,50 € | Add a book | 3 modifications enregistrées
       after 1 click: Un livre       after 2 clicks: 2 livres

Two things came for free: the date and currency pipes used French formats because the build set LOCALE_ID to fr (no registerLocaleData call needed with localize), and the placeholder attribute was translated.

For ng serve, which serves one locale at a time, add a configuration with "localize": ["fr"] and run ng serve -c fr.

Missing translations — a trap

Notice "Add a book" stayed English in the French build, and the build printed nothing. That unit existed in messages.fr.xlf but had no <target>, and a unit without a target silently falls back to the source text. Only when we removed the unit completely did the build complain:

▲ [WARNING] No translation found for "addBook" ("Add a book").        (default)
✘ [ERROR] No translation found for "addBook" ("Add a book").          ("i18nMissingTranslation": "error")

So set "i18nMissingTranslation": "error" in CI, and check that every unit has a <target> (most translation tools report untranslated units; a short script over the XLIFF works too).

Deploying multiple locales

Each locale is a separate static app under its own base path. Serve /fr/* from the fr folder and /en-US/* from en-US (each with its own SPA fallback), and choose the initial locale with a redirect from / based on the Accept-Language header or a saved preference. Switching language is a navigation to the other build, not a runtime toggle. If you genuinely need runtime language switching without reloading, third-party libraries (for example Transloco) take a runtime-lookup approach instead — with its own trade-offs in bundle size and SSR.

How It Actually Works

The compiler turns every i18n-marked template string into a $localize tagged template call, so templates and TypeScript use the same mechanism. In a localized build, the application builder first compiles the app once, then for each locale runs a translation pass over the compiled JavaScript: it finds each $localize\…`call, looks up the message ID in that locale's translation file, and replaces the call with the translated string literal (reordering placeholders as the translation says). It also inlines that locale's data (date formats, plural rules, currency symbols) and setsLOCALE_ID`. Because translation happens after compilation, building ten locales costs far less than ten full compilations — and at runtime there is no lookup at all.

$localize is also defined at runtime (the @angular/localize/init polyfill) so that un-translated builds and ng serve work; in that case it just assembles the source string.

Common mistakes

  • Concatenating fragments ('Saved ' + n + ' changes') instead of one message with a placeholder. Word order differs between languages.
  • No custom IDs, so every copy edit invalidates translations.
  • Relying on the default warning for missing translations; empty targets don't warn.
  • Hard-coding formats (toFixed(2) + '€') instead of locale-aware pipes.
  • Forgetting RTL languages (Arabic, Hebrew): use logical CSS properties (margin-inline-start) so layouts flip correctly. Our French build's <html> had dir="ltr"; an RTL locale gets dir="rtl".

Exercise

  1. Add @angular/localize to a project, mark ten strings (including one attribute, one $localize in code and one ICU plural), and extract them.
  2. Add a second locale of your choice. Translate everything except one message and build — then write a small script that fails if any <trans-unit> lacks a <target>.
  3. Set "i18nMissingTranslation": "error", delete a unit from the translation file and confirm the build fails.
  4. Add a select ICU ({gender, select, male {…} female {…} other {…}}) and translate it.
  5. Configure ng serve -c <locale> for your second language.