Angular has built-in internationalization through the @angular/localize package. You mark the texts in templates with the i18n attribute, and in TypeScript with $localize. The CLI extracts them into a translation file (XLIFF, JSON…), and at build time it produces one variant of the application per language, with the texts already replaced. The result is as fast as the untranslated application: no key lookup at runtime.
Besides texts, i18n also means plurals and formatting. “2 files”, but “20 files” in Romanian is “20 de fișiere”; 1,499.50 in English and 1.499,50 in Romanian; the date with the name of the day in the user’s language. This is where ICU expressions and locale data come in.
Marking the texts
ng add @angular/localize installs the package and loads @angular/localize/init as a polyfill. The i18n attribute marks an element; its optional value has the form meaning|description@@id. The meaning and the description help the translator. A custom id (@@cart.title) keeps the translation from being lost when you change the source text. For attributes you use i18n-alt, i18n-title etc. In code, $localize is a tagged template with the same syntax, and the interpolations receive names (${count}:count:).
ng add @angular/localize
# adds @angular/localize/init to the polyfills and the types to tsconfig — without it, i18n and $localize don't startICU: plural and select
ICU expressions choose the text according to a value. plural uses the CLDR categories of the language (LOCALE_ID), plus exact matches (=0). select chooses according to a string. Romanian has three categories:
onefor 1;fewfor 0, 2–19 and 101–119;otherfor the rest (20, 100, 120…), where “de” appears: “20 de fișiere” (20 files).
The rule is in the Angular locale data (@angular/common/locales/ro). ICU expressions work in any template, even without translations.
You uploaded 2 files as editor.
CLDR category for 2 in Romanian:few
Extraction and translation files
ng extract-i18n gathers all the marked messages. By default it writes messages.xlf (XLIFF 1.2); with --format you can also choose XLIFF 2, XMB, JSON or ARB. You copy the file for each language (messages.en.xlf), the translators fill in the <target> elements, and the ids link the source to its translation.
ng extract-i18n --output-path src/locale # default: messages.xlf (XLIFF 1.2)
ng extract-i18n --format=xlf2 --output-path src/locale
ng extract-i18n --format=json --output-path src/localeBuild per language vs runtime translations
The standard approach is at build time: in angular.json you declare sourceLocale and locales, and with "localize": true the CLI produces one folder per language (/ro/, /en/…). The server chooses the folder according to the URL or the user’s preference. Changing the language means navigating to another folder, therefore a reload.
The alternative is at runtime: a single build, and in main.ts you load the translations with loadTranslations() before bootstrap. Here too, changing the language means a reload, because the texts marked with i18n in templates are evaluated when the components are created. If you need to change the language without a reload, use a runtime translation library.
{
"projects": {
"shop": {
"i18n": {
"sourceLocale": "ro",
"locales": {
"en": "src/locale/messages.en.xlf",
"de": "src/locale/messages.de.xlf"
}
},
"architect": {
"build": {
"options": { "localize": true }
}
}
}
}
}Your cart
You have 3 items in your cart.
The messages are $localize calls with ids (@@cart.title), translated with loadTranslations(). This demo has its own buttons and does not follow the site’s language.
The demo re-evaluates the $localize calls in code. Texts marked with i18n in templates do not change this way, which is why real applications reload the page when the language changes.
Formatting by locale
The date, number, currency and percent pipes (and the functions formatDate, formatNumber…) use LOCALE_ID. For any language other than en-US you must register the data with registerLocaleData. This site does it for ro, with RON as the default currency (DEFAULT_CURRENCY_CODE). All the pipes also accept an explicit locale parameter.
import { registerLocaleData } from '@angular/common';
import localeRo from '@angular/common/locales/ro';
import { DEFAULT_CURRENCY_CODE, LOCALE_ID } from '@angular/core';
registerLocaleData(localeRo); // required for any locale other than en-US
export const appConfig: ApplicationConfig = {
providers: [
{ provide: LOCALE_ID, useValue: 'ro' }, // used by date, number, currency, percent, ICU
{ provide: DEFAULT_CURRENCY_CODE, useValue: 'RON' },
],
};| Locale | Date | Number | Currency (EUR) | Percent |
|---|---|---|---|---|
ro | vineri, 9 octombrie 2026 | 1.234.567,89 | 1.499,50 € | 18,8 % |
en | Friday, October 9, 2026 | 1,234,567.89 | €1,499.50 | 18.8% |
de | Freitag, 9. Oktober 2026 | 1.234.567,89 | 1.499,50 € | 18,8 % |
ja | 2026年10月9日金曜日 | 1,234,567.89 | €1,499.50 | 18.8% |
How this site switches language
The site does not use build-time i18n for its own text. The interface language is a signal: the LangStore service (in core/i18n), with lang() and the values 'en' or 'ro'. The button in the topbar calls toggle(), which saves the choice in localStorage under the key atlas-lang. On startup the key is read; if it is missing, the language is English. An effect keeps the lang attribute of the <html> element in sync.
Switching does not reload the page: text is evaluated again when the signal changes. There are two kinds of bilingual text:
- The interface (topbar, menus, search) has keys in
messages.ts, typed withMessageKey. Thetpipe translates them:{{ 'topic.minRead' | t: { n: 4 } }}. TypeScript requires every key to exist in both languages. - The texts of this site live in dictionaries, one JSON file per language (
i18n/en/…,i18n/ro/…,i18n/de/…). Each text has a key and is read with thetpipe:{{ 'signals:title' | t }}. The pipe is impure, so it is re-evaluated when the language changes.
The prose of a page is written in its template in both languages (@if (lang.lang() === 'en')), and its code is in two .snippets files. The $localize demo above is a different mechanism with its own buttons: it does not follow the site’s language.
When YES, when NO
- Public applications, SEO, performance: each language is an optimized build, with no keys at runtime.
- Languages known at build time and changed rarely (the preference in the account, the
/en/URL). - Teams with professional translators who work in XLIFF.
- Works well with SSR / prerender: one variant per language (see SSR).
- Changing the language without a reload is a product requirement.
- The translations come from a CMS and change without a deploy.
- Dozens of languages, and you don’t want dozens of builds.
- Small internal applications in a single language: just
LOCALE_ID+ pipes + ICU, without extraction.
Common pitfalls
- Concatenating texts (
'You have ' + n + ' items'): the word order differs between languages. Use a single message with a placeholder, or an ICU expression. - Plurals with
n === 1 ? … : …: in Romanian you get “20 fișiere” instead of “20 de fișiere”. Use the ICUplural. - Messages without a custom id: the generated id changes with any correction of the text, and the translation is lost.
- Hard-coded formats (
'dd/MM/yyyy',toFixed(2) + ' lei'): they bypass the locale. Use the pipes. - Unregistered locale:
LOCALE_IDset torowithoutregisterLocaleDatathrows an error at the first pipe.
Cheat sheet
MARKING i18n="meaning|description@@id" · i18n-title="@@id" · $localize`:@@id:text ${x}:name:`
ICU {n, plural, =0 {…} one {…} few {…} other {…}} · {x, select, a {…} other {…}}
Romanian: one = 1 · few = 0, 2–19, 101–119 · other = 20+, 120+ (“20 de fișiere” = 20 files)
EXTRACTION ng extract-i18n [--format=xlf|xlf2|xmb|json|arb] [--output-path src/locale]
BUILD angular.json: i18n.sourceLocale + i18n.locales · "localize": true → one bundle per language
RUNTIME loadTranslations() before bootstrap · clearTranslations() · new language = reload
FORMATTING registerLocaleData + LOCALE_ID + DEFAULT_CURRENCY_CODE · pipes with the locale parameter
ALTERNATIVE runtime translation libraries (e.g. Transloco, ngx-translate) — language change without reload