Angular i18n with $localize: Translate, Pluralize and Build per Locale [2026]
Translating an Angular app looks like a string-replacement job until you meet the details: "1 item" versus "3 items", a word order that changes in German, a date that should read 13/10/2026 in Paris and 10/13/2026 in Chicago, and a button label that must be translated without breaking its aria-label. Angular's built-in i18n handles all of this at build time, but only if you mark the text correctly and set up the build and the server to match.
This is lesson 14.6 of the Angular Tutorial. It follows lesson 14.5 on Angular Material, and it builds on pipes from Module 2, since date, number and currency formatting are part of localization. You'll learn how to mark text with i18n and $localize, handle plurals with ICU expressions, extract and translate messages, build and serve one bundle per locale, and when a runtime translation library is the better choice.
How Angular i18n works #
Angular's built-in approach is compile-time translation. You mark text in templates and code, extract it into a translation file, translators fill in the targets, and the CLI produces a separate, fully translated build for each locale:
| Step | Tool | Output |
|---|---|---|
| Mark | i18n attributes, $localize in TypeScript |
Source code with translatable messages |
| Extract | ng extract-i18n |
messages.xlf (or JSON, ARB, XMB) with every source message |
| Translate | A translator or translation tool | messages.fr.xlf, messages.de.xlf, … |
| Build | ng build with localize |
One output folder per locale, translations inlined |
| Serve | Your web server or SSR | Each user gets the folder for their language |
Because translations are inlined at build time, there is no lookup cost at runtime and no flash of untranslated text. The trade-off is that switching language means loading a different build, which is usually a full page navigation.
Setup #
ng add @angular/localize
This adds the package and the @angular/localize types to your TypeScript configuration, so the global $localize function type-checks. By default the package goes into devDependencies; pass --use-at-runtime only if you plan to load translations at runtime instead of at build time.
Marking template text #
Add the i18n attribute to any element whose text should be translated. The value is optional metadata in the form meaning|description@@id:
<h1 i18n="site header|Title on the landing page@@landingTitle">Welcome to the shop</h1>
<p i18n="@@cartEmpty">Your cart is empty.</p>
<!-- translate text without adding an element -->
<ng-container i18n="@@freeShipping">Free shipping over 50 euros</ng-container>
Each part has a job:
| Part | Purpose | Example |
|---|---|---|
| meaning | Disambiguates identical text with different senses; part of the generated ID | site header |
| description | Context for the translator; does not affect the ID | Title on the landing page |
@@id |
A custom, stable ID instead of a generated hash | @@landingTitle |
Use custom IDs. Without them, the ID is derived from the text and meaning, so fixing a typo in the source changes the ID and orphans the existing translation in every language file.
Interpolations and nested elements inside an i18n element become placeholders, so translators can move them without touching your markup:
<p i18n="@@greeting">Hello {{ user().name }}, you have <strong>{{ count() }}</strong> new messages.</p>
Attributes #
Attributes aren't translated by i18n on the element. Mark each one with i18n-<attribute>:
<input
type="search"
placeholder="Search products"
i18n-placeholder="@@searchPlaceholder"
aria-label="Search the catalogue"
i18n-aria-label="@@searchLabel" />
<img [src]="logoUrl" alt="Company logo" i18n-alt="@@logoAlt" />
Accessibility attributes are the ones most often forgotten. A translated page with an English aria-label reads correctly on screen and wrongly to a screen reader.
Text in TypeScript #
Strings built in component or service code use the $localize tagged template. Metadata goes between colons before the text, and an interpolation can be given a named placeholder:
export class CartPage {
private notify = inject(NotificationService);
name = signal('Sam');
title = $localize`:Page title|Browser tab title for the cart@@cartTitle:Your cart`;
onAdded(product: Product) {
this.notify.show($localize`:@@addedToCart:${product.name}:productName: was added to your cart`);
}
}
$localize is translated at build time in the same pass as templates, so page titles, snackbar messages, validation errors and route titles all end up in the same translation file. Route titles are an easy one to miss:
export const routes: Routes = [
{ path: 'cart', component: CartPage, title: $localize`:@@cartRouteTitle:Your cart` },
{ path: 'orders', component: OrdersPage, title: $localize`:@@ordersRouteTitle:Order history` },
];
Plurals and selections with ICU #
Concatenating "item" + "s" breaks in most languages. ICU expressions let the translator supply the right form for each category:
<span i18n="@@cartCount">
{count(), plural, =0 {Your cart is empty} =1 {One item in your cart} other {{{ count() }} items in your cart}}
</span>
<span i18n="@@orderStatus">
Order {status(), select, shipped {has shipped} delivered {was delivered} other {is being prepared}}
</span>
plural matches exact values (=0, =1) and the language's plural categories (zero, one, two, few, many, other); English uses only one and other, while languages such as Polish or Arabic need more, and the translator adds them in their file. select picks a branch by string value, which is useful for enum-like states. Always include other, and keep the branches complete sentences: translators can't reorder fragments that span several ICU expressions.
Extracting and translating #
ng extract-i18n --output-path src/locale
This writes src/locale/messages.xlf, an XLIFF 1.2 file containing every marked message with its description, meaning and source location. Other formats are available through --format:
--format |
File | Notes |
|---|---|---|
xlf |
.xlf |
XLIFF 1.2; widely supported by translation tools |
xlf2 |
.xlf |
XLIFF 2.0 |
xmb |
.xmb source, .xtb translations |
XML Message Bundle |
json |
.json |
Simple key-value structure |
arb |
.arb |
Application Resource Bundle |
For each language, copy the source file (for example to messages.fr.xlf) and add a <target> for every <source>. Re-run the extraction whenever you add or change text, and merge the new units into each language file; most translation management tools do the merge for you.
Configuring locales and building #
Register the locales in the project's i18n section of angular.json, then turn on localize for the build:
{
"projects": {
"shop": {
"i18n": {
"sourceLocale": "en-US",
"locales": {
"fr": { "translation": "src/locale/messages.fr.xlf" },
"de": { "translation": "src/locale/messages.de.xlf" }
}
},
"architect": {
"build": {
"options": { "localize": true, "i18nMissingTranslation": "error" },
"configurations": {
"fr": { "localize": ["fr"] }
}
},
"serve": {
"configurations": {
"fr": { "buildTarget": "shop:build:development,fr" }
}
}
}
}
}
}
"localize": true builds every defined locale; an array such as ["fr"] builds only those. The build writes one folder per locale inside the output path, sets lang on each index.html, and adjusts each copy's base href to match its folder. i18nMissingTranslation: "error" fails the build when a language file is missing a message, which is what you want in CI.
The dev server serves one locale at a time, which is why the fr configurations exist:
ng serve --configuration=fr # develop against the French build
ng build --configuration=production,fr # one production locale
ng build --localize # all locales
Deploying per-locale builds #
Each locale folder is a complete app. The usual layout is one sub-path per language (/fr/, /de/) with the server redirecting / based on the Accept-Language header and falling back to the source locale. A locale's subPath in angular.json sets both its output folder name and its base href, and baseHref changes only the latter, so you can match whatever URL scheme your hosting needs. With SSR in server output mode, Angular handles the Accept-Language redirect for you; otherwise configure it in Nginx, Apache or your CDN (lesson 9.7 covers edge setups).
Locale-aware formatting #
The date, currency, number and percent pipes format according to the LOCALE_ID token. In a localized build the CLI includes the locale data and sets LOCALE_ID for each locale automatically, so {{ order.total | currency: 'EUR' }} renders 1 234,50 € in the French build without any extra code. A single pipe can override it with its locale argument:
<td>{{ amount | currency: 'USD' : 'symbol' : '1.2-2' : 'en-US' }}</td>
If you don't use the localize build but still want, say, German formatting, register the data and provide the token yourself:
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
registerLocaleData(localeDe);
export const appConfig: ApplicationConfig = {
providers: [{ provide: LOCALE_ID, useValue: 'de' }],
};
Built-in i18n or a runtime library? #
Libraries such as Transloco load translation files at runtime and look keys up as the app renders:
Built-in @angular/localize |
Runtime library (e.g. Transloco) | |
|---|---|---|
| Language switch | New build, usually a page load | Instant, in place |
| Runtime cost | None; text is inlined | Key lookups and translation file downloads |
| Builds and deploys | One per locale | One |
| Source text | Real text in templates | Keys such as cart.empty |
| Maintained by | The Angular team | The library's maintainers |
Choose the built-in approach for public, content-heavy sites where each language has its own URLs and SEO matters. Choose a runtime library when users switch language inside a logged-in app, when translations change without redeploys, or when one build artifact per environment is a hard requirement.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
ng serve errors with several locales |
The dev server supports one locale | Add a locale configuration and run ng serve --configuration=fr |
| Translations vanish after a text edit | Generated IDs changed with the source text | Use custom @@ids everywhere |
aria-label or placeholder stays in English |
Only element text was marked | Add i18n-aria-label, i18n-placeholder |
Assets or lazy chunks 404 under /fr/ |
Server doesn't route the locale sub-path | Serve each folder at its sub-path; fall back to that folder's index.html |
| Wrong plural form in some languages | Plural built by concatenation | Use an ICU plural expression |
| Missing translations ship silently | Default is a warning | Set i18nMissingTranslation: "error" |
What's next #
Lesson 14.7 turns Angular inside out: Angular Elements packages components as standard web components that run in any page, from a CMS to a React app, including how inputs, outputs and Shadow DOM map onto the custom elements API.
Try it yourself #
Pick one screen with a counter (cart, notifications, search results). Mark its text with custom IDs, replace the counter with an ICU plural, run ng extract-i18n, create a French file with two or three targets, and serve it with ng serve --configuration=fr.
search_documentationFor a public storefront, @angular/localize fits better: each language gets its own URLs for search engines, and text is inlined with no runtime lookups. Steps: (1) ng add @angular/localize; (2) mark templates with i18n="@@id", attributes with i18n-aria-label and so on, and code strings with $localize; (3) ng extract-i18n --output-path src/locale, then create messages.fr.xlf and messages.de.xlf; (4) add both under i18n.locales in angular.json and set "localize": true. ng build then writes a folder per locale with the base href set. Serve them at /fr/ and /de/ and redirect / on Accept-Language; your SSR setup in server output mode can do that redirect itself. Pick Transloco instead only if shoppers must switch language without a page load.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


