6. Internationalization
6.1. Example 21: A Bilingual Application
The [RdvMedecins] application is available in French and English. In the [NestJS] course, the server used to translate the pages before sending them. Now, the pages are generated in the browser: the client translates them, and switching languages no longer requires any interaction with the server.
[Angular] has its own translation system, [@angular/localize], which generates a compiled application for each language: switching languages therefore simply involves loading a different application. Instead, we’ll use the [ngx-translate] library, the most commonly used one alongside [Angular], which translates at runtime: a single click is all it takes to retranslate the entire page.
| 21-i18n/src/app/
├── i18n/
│ ├── fr.json the French dictionary
│ ├── en.json the English dictionary
│ ├── i18n.ts the configuration of ngx-translate
│ └── plural.pipe.ts the choice of the plural
├── app.ts
└── app.html
|
The library is declared in the [package.json] file in the workspace:
| "@ngx-translate/core": "^18.0.0",
|
6.1.1. Dictionaries
| {
"APP": {
"TITLE": "Exemple 21 - une application en deux langues",
"LANGUAGE": "Langue"
},
"HOME": {
"HELLO": "Bonjour {{name}} !",
"TODAY": "Nous sommes le {{date}}.",
"PRICE": "Une consultation coûte {{price}}.",
"APPOINTMENTS": {
"zero": "aucun rendez-vous",
"one": "un rendez-vous",
"other": "{{n}} rendez-vous"
},
"ADD": "Ajouter un rendez-vous",
"REMOVE": "En retirer un"
},
"ERRORS": {
"LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
"TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {{minutes}} minute(s)."
},
"SERVER": {
"TITLE": "Traduire une réponse du serveur",
"EXPLANATION": "Le serveur RdvMedecins ne renvoie que des clés. C'est le client qui les traduit :"
}
}
|
| {
"APP": {
"TITLE": "Example 21 - a two-language application",
"LANGUAGE": "Language"
},
"HOME": {
"HELLO": "Hello {{name}}!",
"TODAY": "Today is {{date}}.",
"PRICE": "A consultation costs {{price}}.",
"APPOINTMENTS": {
"zero": "no appointment",
"one": "one appointment",
"other": "{{n}} appointments"
},
"ADD": "Add an appointment",
"REMOVE": "Remove one"
},
"ERRORS": {
"LOGIN_TAKEN": "This login is already used by another account.",
"TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minute(s)."
},
"SERVER": {
"TITLE": "Translating a server response",
"EXPLANATION": "The RdvMedecins server only sends keys. The client translates them:"
}
}
|
- Each text has a key consisting of the names of the successive levels: [HOME.HELLO], [ERRORS.LOGIN_TAKEN]... The code and templates no longer contain any text, only keys;
- line 7: parameters are written between double curly braces: [{{name}}];
- lines 10–14: plural forms, grouped under the same key (see below);
- lines 18–21: the keys [ERRORS.*] are those returned by the server [RdvMedecins] in its error responses (next chapter). The server does not return text, but a key; the client translates it into the user’s language.
6.1.2. The configuration
| // =========================================================================
// 21-i18n/src/app/i18n/i18n.ts - the configuration of ngx-translate
// -------------------------------------------------------------------------
// ngx-translate is the most widely used translation library with
// Angular. We provide it with:
// - a DICTIONARY per language (fr.json, en.json), where each text has a
// KEY ("HOME.HELLO"); the parameters are written {{name}};
// - a LOADER (TranslateLoader), which returns the dictionary of a
// language. Ours takes them from the code of the application (import of
// JSON files); another one could download them;
// - the starting language (the one stored in localStorage, otherwise that
// of the browser, otherwise English) and the fallback language.
// In the templates, {{ 'HOME.HELLO' | translate: { name: 'Marie' } }}
// displays the text of the CURRENT language; changing the language updates
// the whole page.
//
// It is, on the client side, the same principle as the NestJS course (dictionaries
// i18n/*.json + helper {{t}}) - but the translation is now done in
// the browser, and changing the language no longer asks anything of the server.
//
// (Angular also has its own system, @angular/localize, which builds ONE
// compiled application per language: the language can then only be changed
// by changing page. ngx-translate, on the other hand, translates at runtime.)
// =========================================================================
import { Provider } from '@angular/core';
import { provideTranslateLoader, provideTranslateService, TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import fr from './fr.json';
import en from './en.json';
export type Language = 'fr' | 'en';
export const LANGUAGES: Language[] = ['fr', 'en'];
export const LANGUAGE_KEY = 'example21.language';
// the loader of the dictionaries
class DictionariesLoader implements TranslateLoader {
getTranslation(language: string): Observable<TranslationObject> {
return of(language === 'en' ? en : fr);
}
}
function initialLanguage(): Language {
const stored = localStorage.getItem(LANGUAGE_KEY);
if (stored === 'fr' || stored === 'en') {
return stored;
}
return navigator.language.startsWith('fr') ? 'fr' : 'en';
}
export function provideI18n(): Provider[] {
return provideTranslateService({
loader: provideTranslateLoader(DictionariesLoader),
fallbackLang: 'en', // a key missing in French: the English one is used
lang: initialLanguage(),
});
}
|
- Lines 29–30: The dictionaries are imported as modules; they are part of the application code. This is made possible by the [resolveJsonModule] option in the [tsconfig.json] file;
- line 34: the key under which the selected language is stored in [localStorage] (example 20);
- lines 37–41: the dictionary loader: [ngx-translate] calls it to retrieve a language’s dictionary in the form of an observable. Ours retrieves them from the code; another might download them from the server;
- lines 43–49: the source language: the one the user last selected, otherwise French if the browser’s language is French, otherwise English;
- lines 51–57: [provideTranslateService] makes the services of [ngx-translate] available throughout the application. [fallbackLang]: the language used for a key missing from the current dictionary.
| // =========================================================================
// 21-i18n/src/main.ts
// -------------------------------------------------------------------------
// provideI18n(): ngx-translate (see app/i18n/i18n.ts).
// registerLocaleData(localeFr): the French rules of the date,
// currency... pipes (English is always available).
// =========================================================================
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { provideI18n } from './app/i18n/i18n';
registerLocaleData(localeFr);
bootstrapApplication(App, { providers: [provideI18n()] }).catch((error) => console.error(error));
|
- line 15: the pipes [date] and [currency] (example 09) know the rules for English; those for French must be registered;
- line 17: the services for [ngx-translate] are added to the application.
6.1.3. Plurals
[ngx-translate] does not support plurals. A small pipe selects the correct form:
| // =========================================================================
// 21-i18n/src/app/i18n/plural.pipe.ts - choosing the plural form
// -------------------------------------------------------------------------
// ngx-translate does not handle plurals. The dictionary gives the
// forms under one same key ("APPOINTMENTS": { "zero", "one", "other" }) and
// this pipe chooses the right one, depending on the number and the language:
// {{ 'HOME.APPOINTMENTS' | plural: n : language | translate: { n } }}
// Intl.PluralRules knows the rules of each language ("one" or "other" in
// English as in French, but other forms in Polish, in Arabic...).
// =========================================================================
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'plural' })
export class PluralPipe implements PipeTransform {
transform(key: string, n: number, language: string | null): string {
if (n === 0) {
return `${key}.zero`;
}
return `${key}.${new Intl.PluralRules(language ?? 'en').select(n)}`;
}
}
|
- Lines 16–21: The pipe receives a key ([HOME.APPOINTMENTS]), a number, and a language; it returns the key in the correct form ([HOME.APPOINTMENTS.one], for example);
- Line 18: Zero has its own format (“no appointment”);
- line 20: [Intl.PluralRules], provided by the browser, follows the rules for each language: [one] or [other] in French and English, but other forms in Polish or Arabic. (Note: In French, 1.5 is singular; in English, it is plural.)
6.1.4. The translated page
| // =========================================================================
// 21-i18n/src/app/app.ts - a translated page
// -------------------------------------------------------------------------
// - 'KEY' | translate a text;
// - 'KEY' | translate: { name: ... } a text with parameters;
// - date / currency with the language a date, an amount, in the format
// of the current language;
// - translate.use('fr') changes the language: the whole
// page is translated again.
// TranslateService.currentLang is a SIGNAL: the current language.
// =========================================================================
import { Component, effect, inject, signal } from '@angular/core';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LANGUAGE_KEY, Language, LANGUAGES } from './i18n/i18n';
import { PluralPipe } from './i18n/plural.pipe';
@Component({
selector: 'app-root',
imports: [CurrencyPipe, DatePipe, TranslatePipe, PluralPipe],
templateUrl: './app.html',
})
export class App {
private readonly translate = inject(TranslateService);
protected readonly language = this.translate.currentLang;
protected readonly languages = LANGUAGES;
protected readonly count = signal(2);
protected readonly today = new Date();
// what the RdvMedecins server would return (see the chapter on the black box)
protected readonly serverResponses = [
{ statusCode: 409, key: 'ERRORS.LOGIN_TAKEN', params: {} },
{ statusCode: 429, key: 'ERRORS.TOO_MANY_ATTEMPTS', params: { minutes: '15' } },
];
// translate.translate(key): a SIGNAL that follows the current language
private readonly title = this.translate.translate('APP.TITLE');
constructor() {
// on each change of language: we store it, and we update
// the lang attribute of the page (useful to screen readers, spell checkers)
effect(() => {
const language = this.language();
if (language) {
localStorage.setItem(LANGUAGE_KEY, language);
document.documentElement.lang = language;
}
document.title = String(this.title());
});
}
protected choose(language: Language): void {
this.translate.use(language);
}
}
|
- line 21: [TranslatePipe]: the pipe [translate];
- line 25: [TranslateService]: the main service of [ngx-translate];
- line 26: [currentLang]: the current language, as a signal;
- lines 32–35: two error responses, as the server would return them: a key and parameters;
- line 38: [translate.translate(key)] returns a signal equal to the translation of the key, which follows the current language;
- lines 43–50: an action executed whenever the language changes: the language is stored (line 46), the page’s [lang] attribute is updated (line 47; this is used by screen readers and spell-checkers), as well as the tab title (line 49);
- line 54: [use] changes the current language: the entire page is re-translated.
| <div class="container py-4" style="max-width: 720px">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ 'APP.TITLE' | translate }}</h1>
<div class="btn-group btn-group-sm" role="group" [attr.aria-label]="'APP.LANGUAGE' | translate">
@for (l of languages; track l) {
<button class="btn" [class]="language() === l ? 'btn-primary' : 'btn-outline-primary'" (click)="choose(l)">
{{ l.toUpperCase() }}
</button>
}
</div>
</div>
<div class="card p-3 mb-3">
<p>{{ 'HOME.HELLO' | translate: { name: 'Marie' } }}</p>
<!-- a parameter computed by another pipe, in the current language -->
<p>{{ 'HOME.TODAY' | translate: { date: (today | date: 'fullDate' : '' : language() ?? 'en') } }}</p>
<p>{{ 'HOME.PRICE' | translate: { price: (1234.5 | currency: 'EUR' : 'symbol' : '1.2-2' : language() ?? 'en') } }}</p>
<!-- plural chooses the key (zero, one, other), translate translates it -->
<p class="fw-bold">{{ 'HOME.APPOINTMENTS' | plural: count() : language() | translate: { n: count() } }}</p>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-success" (click)="count.set(count() + 1)">{{ 'HOME.ADD' | translate }}</button>
<button class="btn btn-sm btn-outline-secondary" [disabled]="count() === 0" (click)="count.set(count() - 1)">
{{ 'HOME.REMOVE' | translate }}
</button>
</div>
</div>
<div class="card p-3">
<h2 class="h6">{{ 'SERVER.TITLE' | translate }}</h2>
<p>{{ 'SERVER.EXPLANATION' | translate }}</p>
<ul class="mb-0">
@for (r of serverResponses; track r.key) {
<!-- the key of the server IS a key of the dictionary; params provides its parameters -->
<li><code>{{ r.statusCode }} {{ r.key }}</code><br>→ {{ r.key | translate: r.params }}</li>
}
</ul>
</div>
</div>
|
- line 3: ['APP.TITLE' | translate]: the translation of the key in the current language;
- line 6: the current language button is highlighted;
- line 14: a parameterized text: the pipe receives an object that provides the value of each parameter;
- lines 16–17: the value of a parameter can be calculated by another pipe. The last argument of [date] and [currency] is the language: the date and amount are written in the French or English style;
- line 19: two chained pipes: [plural] selects the key, and [translate] translates it;
- line 34: the key returned by the server is translated, along with its parameters.
[npm start 21-i18n]; with a browser set to French, the page is in French:

Click on [EN], then on “Remove one”:

Reload the page: it remains in English; the language has been saved.