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¶
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¶
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`);
}
i18nmarks 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(ori18n-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. $localizeis the tagged template for text in TypeScript.:@@id:sets the ID, and${value}:name:names a placeholder.
3. Extract¶
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¶
"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>haddir="ltr"; an RTL locale getsdir="rtl".
Exercise¶
- Add
@angular/localizeto a project, mark ten strings (including one attribute, one$localizein code and one ICU plural), and extract them. - 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>. - Set
"i18nMissingTranslation": "error", delete a unit from the translation file and confirm the build fails. - Add a
selectICU ({gender, select, male {…} female {…} other {…}}) and translate it. - Configure
ng serve -c <locale>for your second language.