Angular Internationalisation — i18n, Locale Pipes, and ngx-translate

Angular’s built-in internationalisation (i18n) system allows you to translate your application into multiple languages, format dates, numbers, and currencies according to locale conventions, and deploy separate locale-specific builds. While Angular’s compile-time i18n approach produces the smallest possible bundles per locale, many teams prefer a runtime approach using libraries like @ngx-translate/core or the Transloco library — which allows language switching without a page reload. This lesson covers both approaches plus Angular’s built-in locale-aware pipes and ICU message format for pluralisation and gender-aware text.

i18n Approaches

Approach Bundle per locale Runtime switch Best For
Angular built-in ($localize) Yes — one build per locale No — requires page reload Apps deployed per locale, SEO-critical multilingual sites
ngx-translate No — single build, JSON files loaded at runtime Yes Apps with runtime language switching, SPAs
Transloco No — single build, JSON files Yes Modern alternative to ngx-translate with better Angular integration
Angular built-in pipes No extra setup Yes (via LOCALE_ID) Date, number, currency formatting for any registered locale

Angular Built-in Locale-Aware Pipes

Pipe Example Output (en-US)
date now | date:'longDate':'':'fr' 15 janvier 2025
number 1234567.89 | number:'1.2-2':'de' 1.234.567,89
currency 99.9 | currency:'EUR':'symbol':'1.2-2':'fr' 99,90 €
percent 0.75 | percent:'1.1-1':'ar' ٧٥٫٠٪
i18nPlural count | i18nPlural:mapping Contextual plural string
i18nSelect gender | i18nSelect:genderMap Gender-specific text
Note: Angular’s built-in locale pipes use the LOCALE_ID injection token to determine the active locale. Register additional locales by importing their data: registerLocaleData(localeFr, 'fr'). Without registering locale data, using | date:'longDate':'':'fr' throws a runtime error. In Angular 14+, you can provide the locale as an injection token: { provide: LOCALE_ID, useValue: 'fr-FR' } in your app config.
Tip: The i18nPlural pipe handles grammatically correct plural forms without *ngIf chains. Define a mapping object: { '=0': 'No tasks', '=1': 'One task', 'other': '# tasks' }. The # is replaced by the count value. For languages with complex plural rules (Russian, Arabic, Polish), use ICU message format inside Angular’s built-in $localize template literals, which understands CLDR plural categories.
Warning: When using ngx-translate or Transloco, avoid translating inside component TypeScript files via translate.instant('key') unless you are sure the language file is already loaded. instant() returns the key string if the translation is not yet loaded (async). Use translate.get('key') (returns Observable) or the | translate pipe in templates, which automatically updates when the language changes.

i18n Implementation Examples

// ── Angular built-in locale pipes ─────────────────────────────────────────
// app.config.ts — register locale data
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import localeDe from '@angular/common/locales/de';
import localeAr from '@angular/common/locales/ar';
import { LOCALE_ID }         from '@angular/core';

registerLocaleData(localeFr, 'fr-FR');
registerLocaleData(localeDe, 'de-DE');
registerLocaleData(localeAr, 'ar');

export const appConfig = {
    providers: [
        { provide: LOCALE_ID, useValue: navigator.language || 'en-US' },
    ],
};

// ── i18nPlural and i18nSelect pipes ───────────────────────────────────────
@Component({
    standalone: true,
    imports: [CommonModule],
    template: `
        <!-- i18nPlural: count-based text -->
        <p>{{ taskCount | i18nPlural: taskPluralMap }}</p>

        <!-- i18nSelect: gender-based text -->
        <p>{{ user.gender | i18nSelect: assignedMap }}</p>

        <!-- Date with explicit locale -->
        <time>{{ task.dueDate | date:'fullDate':'':currentLocale }}</time>

        <!-- Currency with locale -->
        <span>{{ price | currency:'USD':'symbol':'1.2-2':currentLocale }}</span>

        <!-- Number with locale-specific thousands/decimal separators -->
        <span>{{ 1234567.89 | number:'1.2-2':currentLocale }}</span>
    `,
})
export class LocaleExampleComponent {
    taskCount    = 3;
    currentLocale= inject(LOCALE_ID);

    taskPluralMap: { [k: string]: string } = {
        '=0':    'No tasks',
        '=1':    'One task',
        '=2':    'Two tasks',
        'other': '# tasks',
    };

    assignedMap: { [k: string]: string } = {
        'male':   'Assigned to him',
        'female': 'Assigned to her',
        'other':  'Assigned to them',
    };
}

// ── ngx-translate setup ───────────────────────────────────────────────────
// npm install @ngx-translate/core @ngx-translate/http-loader

// app.config.ts
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader }              from '@ngx-translate/http-loader';

export function createTranslateLoader(http: HttpClient) {
    return new TranslateHttpLoader(http, '/assets/i18n/', '.json');
}

export const appConfig = {
    providers: [
        importProvidersFrom(
            TranslateModule.forRoot({
                defaultLanguage: 'en',
                loader: {
                    provide: TranslateLoader,
                    useFactory: createTranslateLoader,
                    deps: [HttpClient],
                },
            })
        ),
    ],
};

// Translation JSON: /assets/i18n/en.json
// {
//   "TASK": {
//     "TITLE": "Task Title",
//     "CREATE": "Create Task",
//     "DELETE_CONFIRM": "Delete '{{ title }}'? This cannot be undone.",
//     "COUNT": {
//       "NONE": "No tasks",
//       "ONE": "1 task",
//       "MANY": "{{ count }} tasks"
//     }
//   }
// }

// Component using ngx-translate
@Component({
    standalone: true,
    imports: [TranslateModule],
    template: `
        <!-- translate pipe -->
        <label>{{ 'TASK.TITLE' | translate }}</label>

        <!-- with interpolation params -->
        <p>{{ 'TASK.DELETE_CONFIRM' | translate: { title: task.title } }}</p>

        <!-- translate directive -->
        <button [translate]="'TASK.CREATE'"></button>
    `,
})
export class TranslatedTaskComponent {
    private translate = inject(TranslateService);
    task = input.required<Task>();

    ngOnInit(): void {
        // Set language from user preference
        const savedLang = localStorage.getItem('lang') ?? 'en';
        this.translate.use(savedLang);
    }

    switchLanguage(lang: string): void {
        this.translate.use(lang);
        localStorage.setItem('lang', lang);
    }
}

How It Works

Step 1 — LOCALE_ID Drives All Built-In Pipe Formatting

Angular’s date, number, currency, and percent pipes all read the LOCALE_ID token to determine how to format values. The locale determines decimal separator (period vs comma), thousands separator, currency symbol placement, date format order, and number of decimal places. Providing { provide: LOCALE_ID, useValue: 'de-DE' } changes all pipe formatting to German conventions without changing any template code.

Step 2 — registerLocaleData Is Required for Non-en-US Locales

Angular ships with only en-US locale data built in. For any other locale, you must explicitly call registerLocaleData(localeData, 'locale-id') before using pipes with that locale. The locale data packages are in @angular/common/locales/ — one file per locale. Failing to register results in a runtime error when the pipe tries to access the locale’s formatting rules.

Step 3 — ngx-translate Loads JSON Files at Runtime

The HTTP loader fetches translation JSON files from /assets/i18n/{lang}.json on demand. When translate.use('fr') is called, it downloads /assets/i18n/fr.json and registers all key-value pairs. The | translate pipe subscribes to language change events and automatically re-renders with the new language. This single-build, runtime-switching model allows language selection without page reload.

Step 4 — i18nPlural Implements CLDR Plural Rules

The i18nPlural pipe supports exact values (=0, =1) and CLDR categories (zero, one, two, few, many, other). For English, only one (exactly 1) and other (everything else) apply. For Russian, up to 4 different plural forms exist. This handles grammatically correct pluralisation that simple ternary operators cannot — without requiring custom logic in the component class.

Step 5 — translate.instant() vs translate.get()

translate.instant('key') returns the translation synchronously — but returns the key itself if the translation file is not yet loaded (async). Only safe in ngOnInit() if you are certain the language is loaded. translate.get('key') returns an Observable that emits when the translation is available — safe to use at any time. Always prefer the | translate pipe in templates over either method, as it handles async loading and language switching automatically.

Quick Reference

Task Code
Register locale registerLocaleData(localeFr, 'fr-FR') in app startup
Set locale { provide: LOCALE_ID, useValue: 'fr-FR' }
Date in locale date | date:'longDate':'':'fr-FR'
Plural text count | i18nPlural: { '=0': 'None', '=1': 'One', 'other': '# items' }
Gender text gender | i18nSelect: { 'male': 'He', 'female': 'She', 'other': 'They' }
ngx-translate pipe 'TASK.TITLE' | translate
ngx-translate with params 'KEY' | translate: { name: user.name }
Switch language inject(TranslateService).use('fr')
Get current locale inject(LOCALE_ID)

🧠 Test Yourself

A component uses {{ 1234.56 | number:'1.2-2' }}. The locale is set to German (de-DE). What is the output, and what must be called first for this to work?