Skip to content

i18n — @angular/localize, ngx-translate, Transloco

TL;DR — Angular propose deux grandes familles pour l'internationalisation. @angular/localize (officiel) fait l'extraction au build : un bundle par locale, performant (pas de runtime overhead, pas de JSON à charger), mais switch de langue impossible sans reload (chaque locale est servie sous son sous-chemin /fr/, /en/). C'est la solution recommandée pour les sites publics. ngx-translate (communautaire, historique) et Transloco (moderne, signals-friendly) chargent les traductions en JSON au runtime, permettant le switch instantané et le lazy-loading par feature. Critères de choix : nombre de locales, SEO (@angular/localize plus simple), changement de langue dynamique nécessaire ou non, taille du bundle. En multi-tenant, ajouter un namespace ou un overlay de traductions par tenant. Toujours marquer le code avec i18n ou $localize dès le départ — ré-internationaliser après coup est très coûteux.

🧠 Mental model — ASCII + analogie

L'analogie : traduire une app = traduire un menu de restaurant. Deux approches :

  • @angular/localize = imprimer un menu par langue (un par locale). Le client choisit le restaurant en français ou en anglais. Avantage : aucune confusion, lecture instantanée. Inconvénient : changer de langue = changer de salle.
  • ngx-translate / Transloco` = un seul menu avec un lecteur multilingue qui traduit à la volée. Avantage : le client change de langue à table. Inconvénient : il faut le lecteur (runtime JS) en plus.
┌──────────────────────────────────────────────────────────────┐
│             @angular/localize (build-time)                   │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  ng build → dist/mon-app/browser/                    │   │
│  │             ├─ fr/ ← bundle FR (chaînes FR figées)   │   │
│  │             ├─ en/ ← bundle EN                       │   │
│  │             └─ es/ ← bundle ES                       │   │
│  │                                                      │   │
│  │  Pas de JSON runtime. Plus rapide, plus petit.       │   │
│  │  Switch langue = redirect vers /fr/ ou /en/.         │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│             ngx-translate / Transloco (runtime)              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  ng build → dist/mon-app/browser/                    │   │
│  │             ├─ main.js (un seul bundle)              │   │
│  │             └─ assets/i18n/                          │   │
│  │                ├─ fr.json                            │   │
│  │                ├─ en.json                            │   │
│  │                └─ es.json                            │   │
│  │                                                      │   │
│  │  Switch langue = fetch JSON + re-render.             │   │
│  │  Plus flexible, mais runtime + bundle plus lourd.    │   │
│  └──────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────┘

Table de décision — le bon outil en 30 secondes

Critère@angular/localizengx-translateTransloco
Moment de résolutionBuild-time (un bundle/locale)Runtime (fetch JSON)Runtime (fetch JSON)
Switch de langue sans reload❌ (redirect /fr//en/)✅ instantané✅ instantané
Runtime overheadZéro (chaînes inlinées)~loader + service~loader + service
Coût buildN×temps de build (1/locale)*1 build1 build
SEO multi-localeNatif (URLs distinctes)À câbler à la mainÀ câbler à la main
Lazy-load par feature❌ (tout figé au build)Partielscopes
Signals-friendlyn/a (statique)via toSignalselectTranslate + toSignal (v6+)
ICU plural/select✅ natif (CLDR)via plugin✅ via transloco-messageformat
SSR/hydration✅ (v18+ correct)
Maintenance amontÉquipe Angulargelé (mode maintenance)actif
Bundle finalle plus petit+JSON chargé au boot+JSON (lazy possible)

*Le builder esbuild (v17+) parallélise et inline les locales : N locales ne coûtent plus N builds complets, c'est une passe d'inlining post-build, quasi gratuite. C'est ce qui a rendu @angular/localize à nouveau attractif.

Règle d'architecte : par défaut, @angular/localize pour tout site public (perf + SEO gratuits) ; Transloco dès qu'il faut un switch runtime, du multi-tenant, ou du lazy par scope. ngx-translate uniquement en legacy — il est en mode maintenance, ne pas le choisir pour un greenfield.

🛠️ Code minimal (ts + html)

@angular/localize — installation et extraction

bash
ng add @angular/localize

Cela ajoute @angular/localize aux dépendances et enregistre son polyfill (entrée @angular/localize/init dans le tableau polyfills de angular.json sur Angular 17+ ; un import dans polyfills.ts sur les versions plus anciennes).

Marquer une chaîne dans le template :

html
<h1 i18n="@@home.title">Bienvenue sur notre site</h1>

<button i18n-title="@@home.cta.title" title="Cliquer pour acheter">
  <span i18n="@@home.cta.label">Acheter maintenant</span>
</button>

<!-- ICU pluralisation -->
<p i18n="@@cart.items">
  { itemCount, plural,
    =0 { Aucun article }
    =1 { 1 article }
    other { {{itemCount}} articles }
  }
</p>

<!-- ICU select -->
<p i18n="@@profile.gender">
  { gender, select,
    male { Bienvenue Monsieur }
    female { Bienvenue Madame }
    other { Bienvenue }
  }
</p>

Marquer une chaîne dans le code TypeScript :

ts
import { Component, signal } from '@angular/core';

@Component({ /* ... */ })
export class GreetingComponent {
  name = signal('Marie');

  // $localize tagged template literal
  greet = () => $localize`:@@greet.hello:Bonjour ${this.name()}:userName:`;

  errorMsg = $localize`:@@form.required:Ce champ est requis`;
}

La syntaxe :@@id:texte:placeholder: permet de spécifier un ID stable (@@home.title) — toujours utiliser des IDs explicites, sinon Angular génère un hash basé sur le texte, qui change à chaque retouche.

Extraction et build par locale

bash
# Extrait toutes les chaînes vers messages.xlf (XLIFF 1.2 par défaut)
ng extract-i18n --output-path src/locale --format xlf2

# Pour XMB (Google) :
ng extract-i18n --format xmb

# Pour JSON :
ng extract-i18n --format json
xml
<!-- src/locale/messages.xlf — fichier SOURCE généré par extract-i18n (pas encore traduit) -->
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="2.0" srcLang="fr-FR">
  <file id="ngi18n" original="ng.template">
    <unit id="home.title">
      <segment>
        <source>Bienvenue sur notre site</source>
      </segment>
    </unit>
    <unit id="home.cta.label">
      <segment>
        <source>Acheter maintenant</source>
      </segment>
    </unit>
  </file>
</xliff>

Le fichier source ne contient que des <source>. Le traducteur (ou Crowdin) produit ensuite messages.en.xlf avec un trgLang et un <target> par unité :

xml
<!-- src/locale/messages.en.xlf — fichier TRADUIT consommé par le build en-US -->
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="2.0" srcLang="fr-FR" trgLang="en-US">
  <file id="ngi18n" original="ng.template">
    <unit id="home.title">
      <segment state="translated">
        <source>Bienvenue sur notre site</source>
        <target>Welcome to our site</target>
      </segment>
    </unit>
    <unit id="home.cta.label">
      <segment state="translated">
        <source>Acheter maintenant</source>
        <target>Buy now</target>
      </segment>
    </unit>
  </file>
</xliff>

Erreur de débutant : commiter le fichier source messages.xlf comme s'il était une traduction (srcLang === trgLang, target recopie le source). Le build « marche » mais sert le français sous /en/. L'attribut state (initialtranslatedfinal) est le signal qui permet à un outil pro de tracer ce qui reste à traduire ; un state="initial" non traité est un bug de release, pas un détail.

Configuration multi-locales dans angular.json :

json
{
  "projects": {
    "mon-app": {
      "i18n": {
        "sourceLocale": "fr-FR",
        "locales": {
          "en-US": { "translation": "src/locale/messages.en.xlf", "baseHref": "en/" },
          "es-ES": { "translation": "src/locale/messages.es.xlf", "baseHref": "es/" },
          "de-DE": { "translation": "src/locale/messages.de.xlf", "baseHref": "de/" }
        }
      },
      "architect": {
        "build": {
          "options": {
            "localize": true,
            "i18nDuplicateTranslation": "error",
            "i18nMissingTranslation": "error"
          }
        }
      }
    }
  }
}

Le build produit :

dist/mon-app/browser/
  fr-FR/
    index.html
    main.A1B2.js
  en-US/
    index.html
    main.C3D4.js
  es-ES/
    ...

Servir les locales

Côté nginx :

nginx
# Détection Accept-Language pour redirection /
map $http_accept_language $lang {
  default     fr;
  ~*^en       en;
  ~*^es       es;
  ~*^de       de;
}

server {
  listen 80;
  root /usr/share/nginx/html;

  location = / {
    return 302 /$lang/;
  }

  location ~ ^/(fr|en|es|de)/ {
    try_files $uri $uri/ /$1/index.html;
  }
}

Switch langue (full reload)

ts
@Component({
  template: `
    <select (change)="switch($event)">
      <option value="fr">Français</option>
      <option value="en">English</option>
      <option value="es">Español</option>
    </select>
  `,
})
export class LanguageSelectorComponent {
  switch(event: Event) {
    const lang = (event.target as HTMLSelectElement).value;
    window.location.href = `/${lang}/`;
  }
}

ngx-translate — runtime, switch instantané

bash
npm install @ngx-translate/core @ngx-translate/http-loader
ts
// app.config.ts
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideHttpClient, HttpClient } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';

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

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    importProvidersFrom(
      TranslateModule.forRoot({
        defaultLanguage: 'fr',
        loader: {
          provide: TranslateLoader,
          useFactory: HttpLoaderFactory,
          deps: [HttpClient],
        },
      }),
    ),
  ],
};
json
// src/assets/i18n/fr.json
{
  "home": {
    "title": "Bienvenue sur notre site",
    "cta": { "label": "Acheter maintenant" }
  },
  "cart": {
    "items_zero": "Aucun article",
    "items_one": "1 article",
    "items_other": "{{count}} articles"
  }
}
ts
import { Component, inject } from '@angular/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';

@Component({
  imports: [TranslateModule],
  template: `
    <h1>{{ 'home.title' | translate }}</h1>
    <button>{{ 'home.cta.label' | translate }}</button>
    <select (change)="switch($event)">
      @for (lang of langs; track lang) {
        <option [value]="lang">{{ lang }}</option>
      }
    </select>
  `,
})
export class HomeComponent {
  private translate = inject(TranslateService);
  langs = ['fr', 'en', 'es'];

  switch(e: Event) {
    this.translate.use((e.target as HTMLSelectElement).value);
  }
}

Transloco — moderne, signals-friendly

bash
ng add @jsverse/transloco
ts
// transloco-loader.ts
import { TranslocoLoader, Translation } from '@jsverse/transloco';
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class HttpLoader implements TranslocoLoader {
  private http = inject(HttpClient);
  getTranslation(lang: string) {
    return this.http.get<Translation>(`/assets/i18n/${lang}.json`);
  }
}
ts
// app.config.ts
import { provideTransloco } from '@jsverse/transloco';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideTransloco({
      config: {
        availableLangs: ['fr', 'en', 'es'],
        defaultLang: 'fr',
        fallbackLang: 'fr',
        reRenderOnLangChange: true,
        prodMode: !isDevMode(),
      },
      loader: HttpLoader,
    }),
  ],
};
ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslocoDirective, TranslocoPipe, TranslocoService } from '@jsverse/transloco';

@Component({
  imports: [TranslocoDirective, TranslocoPipe],
  template: `
    <ng-container *transloco="let t">
      <h1>{{ t('home.title') }}</h1>
      <p>{{ t('cart.items', { count: 3 }) }}</p>
    </ng-container>
    <!-- ou avec pipe -->
    <h1>{{ 'home.title' | transloco }}</h1>
    <!-- ou via signal (Transloco v7 + toSignal) -->
    <h1>{{ title() }}</h1>
  `,
})
export class HomeComponent {
  private readonly transloco = inject(TranslocoService);
  // selectTranslate ré-émet à chaque changement de langue → signal réactif
  readonly title = toSignal(this.transloco.selectTranslate('home.title'), {
    initialValue: 'home.title',
  });
}

🎯 Patterns courants

1. Lazy loading des traductions par feature

Avec ngx-translate ou Transloco, on peut charger un namespace de traductions au chargement d'une route lazy. Cela évite de mettre toutes les chaînes de l'app dans un seul JSON.

ts
// Transloco scope
@Component({
  providers: [provideTranslocoScope('checkout')],
  template: `<h1>{{ t('checkout.title') }}</h1>`,
})
export class CheckoutComponent { /* ... */ }

Le fichier assets/i18n/checkout/fr.json est chargé à la demande.

2. Formatage de dates, nombres, devises

Angular fournit nativement DatePipe, DecimalPipe, CurrencyPipe, PercentPipe qui utilisent LOCALE_ID. À configurer :

ts
import { LOCALE_ID } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';

registerLocaleData(localeFr);

export const appConfig: ApplicationConfig = {
  providers: [
    { provide: LOCALE_ID, useValue: 'fr-FR' },
  ],
};

Avec @angular/localize, le LOCALE_ID est défini automatiquement par le build par locale. Aucun setup manuel.

html
<p>{{ today | date:'longDate' }}</p>      <!-- 24 mai 2026 -->
<p>{{ amount | currency:'EUR' }}</p>       <!-- 1 234,56 € -->
<p>{{ ratio | percent:'1.0-1' }}</p>       <!-- 42,3 % -->

3. Multi-tenant — overlay de traductions

Un SaaS multi-tenant peut vouloir personnaliser certaines chaînes par client (« Patient » → « Client » pour un tenant médical → cabinet d'avocat). Pattern : charger d'abord les traductions par défaut, puis fusionner avec les overrides du tenant.

ts
// Transloco — chargement double
async loadForTenant(tenantId: string, lang: string) {
  const base = await firstValueFrom(this.http.get(`/assets/i18n/${lang}.json`));
  const override = await firstValueFrom(
    this.http.get(`/api/tenants/${tenantId}/i18n/${lang}.json`)
  ).catch(() => ({}));
  this.transloco.setTranslation({ ...base, ...override }, lang);
}

4. Workflow de traduction

Avec @angular/localize, le fichier XLIFF se prête bien aux outils pro (Crowdin, Lokalise, Phrase, Localazy). Workflow type :

  1. Dev marque les chaînes avec i18n / $localize.
  2. CI extrait messages.xlf à chaque PR.
  3. Outil de traduction (Crowdin) détecte les nouvelles chaînes, propose aux traducteurs.
  4. Traductions exportées comme messages.<locale>.xlf dans le repo.
  5. Build par locale → déploiement.

5. SEO multi-locale

Toujours ajouter les balises hreflang pour signaler les versions linguistiques à Google :

html
<link rel="alternate" hreflang="fr-FR" href="https://monsite.com/fr/" />
<link rel="alternate" hreflang="en-US" href="https://monsite.com/en/" />
<link rel="alternate" hreflang="x-default" href="https://monsite.com/" />

Idéalement : un sitemap par locale, soumis à Google Search Console.

6. Détection de langue

ts
function detectLang(): string {
  const stored = localStorage.getItem('lang');
  if (stored) return stored;
  const nav = navigator.language || 'fr';
  const short = nav.split('-')[0];
  return ['fr', 'en', 'es'].includes(short) ? short : 'fr';
}

Avec @angular/localize, la détection se fait côté serveur (nginx map ou middleware Express) pour rediriger vers /fr/ ou /en/ à la première visite.

7. i18n de contenu LLM/agent streamé (AI UI)

Un cas qui surprend les équipes qui branchent un agent : on n'internationalise pas le texte généré par le LLM — ce serait coûteux et toujours en retard. On internationalise la coquille UI (boutons, statuts, labels du trace timeline) avec Transloco, et on demande au modèle de produire directement dans la locale active via le system prompt. La locale UI devient un paramètre de la requête.

ts
// chat.service.ts — la locale active pilote le prompt serveur
import { inject, Injectable } from '@angular/core';
import { TranslocoService } from '@jsverse/transloco';

@Injectable({ providedIn: 'root' })
export class ChatService {
  private readonly transloco = inject(TranslocoService);

  async send(prompt: string, signal: AbortSignal) {
    const locale = this.transloco.getActiveLang(); // 'fr' | 'en'
    return fetch('/api/agent/stream', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      // le backend injecte `Réponds en ${locale}` dans le system prompt
      body: JSON.stringify({ prompt, locale }),
      signal, // Stop button → AbortController côté client ET serveur
    });
  }
}

Le rendu des tokens streamés sous zoneless : buffer append-only + coalescing rAF pour ne pas déclencher un re-render par token (un flux Opus 4.8 peut émettre des dizaines de tokens/s). Les labels du timeline (En attente, Outil en cours, Terminé) restent traduits par Transloco — discriminated union pour l'état de chaque step :

ts
// agent-trace.model.ts
export type ToolStep =
  | { kind: 'pending'; tool: string }
  | { kind: 'running'; tool: string; startedAt: number }
  | { kind: 'streaming'; tool: string; partial: string }
  | { kind: 'done'; tool: string; durationMs: number }
  | { kind: 'error'; tool: string; message: string };

// la clé i18n est dérivée du discriminant — un seul mapping
export const STEP_KEY: Record<ToolStep['kind'], string> = {
  pending: 'agent.step.pending',
  running: 'agent.step.running',
  streaming: 'agent.step.streaming',
  done: 'agent.step.done',
  error: 'agent.step.error',
};
ts
// chat.component.ts — streaming token rendering, zoneless, rAF-coalescé
import { ChangeDetectionStrategy, Component, signal, inject, NgZone } from '@angular/core';
import { TranslocoDirective } from '@jsverse/transloco';
import { ChatService } from './chat.service';
import { STEP_KEY, ToolStep } from './agent-trace.model';

@Component({
  selector: 'app-chat',
  imports: [TranslocoDirective],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ng-container *transloco="let t; prefix: 'agent'">
      <article class="message">{{ answer() }}</article>

      @for (step of steps(); track step.tool) {
        <li [class]="step.kind">{{ t(stepKey(step)) }}</li>
      }

      @if (busy()) {
        <button (click)="stop()">{{ t('stop') }}</button>
      }
    </ng-container>
  `,
})
export class ChatComponent {
  private readonly chat = inject(ChatService);
  protected readonly answer = signal('');
  protected readonly steps = signal<ToolStep[]>([]);
  protected readonly busy = signal(false);
  private controller?: AbortController;
  private pendingBuffer = '';
  private rafScheduled = false;

  protected stepKey = (s: ToolStep) => STEP_KEY[s.kind];

  async ask(prompt: string) {
    this.controller = new AbortController();
    this.busy.set(true);
    this.answer.set('');
    const res = await this.chat.send(prompt, this.controller.signal);
    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    try {
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        this.pendingBuffer += decoder.decode(value, { stream: true });
        this.flushCoalesced(); // ne touche le signal qu'1×/frame
      }
    } finally {
      this.busy.set(false);
    }
  }

  // coalescing : N tokens → 1 set de signal par frame d'animation
  private flushCoalesced() {
    if (this.rafScheduled) return;
    this.rafScheduled = true;
    requestAnimationFrame(() => {
      this.rafScheduled = false;
      this.answer.update((prev) => prev + this.pendingBuffer);
      this.pendingBuffer = '';
    });
  }

  stop() {
    this.controller?.abort();     // annule le fetch côté client
    this.busy.set(false);         // le backend voit la déconnexion et coupe l'appel LLM
  }
}

Points seniors :

  • Le markdown du LLM doit passer par un sanitizer (DomSanitizer / bypassSecurityTrustHtml seulement après sanitization d'une lib markdown sûre) — un modèle peut émettre du HTML/<script> dans sa réponse.
  • Ne jamais router le texte du modèle dans le fichier de traduction : il n'a pas de clé stable, change à chaque génération, et exploserait le JSON. La frontière est nette : chrome statique → i18n ; contenu généré → locale-as-prompt-param.
  • Pluriels dans le contenu LLM : faire générer le texte final par le modèle, pas reconstruire des phrases côté front à partir de fragments traduits — sinon on retombe dans le piège de la concaténation, en pire.

🧭 Comment un staff engineer raisonne

  • i18n est une décision d'architecture, pas une feature. Le coût n'est pas « traduire les chaînes » mais « toucher chaque composant ». Donc : on décide build-time vs runtime au jour 1, même mono-locale, et on marque tout dès le début. Une migration tardive est un projet à part entière (voir pitfall #10).
  • Le vrai axe de choix n'est pas la techno, c'est : “l'utilisateur change-t-il de langue dans la session ?” Si non (visiteur SEO qui arrive sur sa locale) → build-time. Si oui (utilisateur authentifié bilingue, multi-tenant) → runtime. Tout le reste découle de là.
  • Locale UI ≠ locale donnée. L'erreur classique : formater une date de document juridique dans la langue de l'interface. Deux tokens distincts (LOCALE_ID pour l'UI, un token scopé route pour la donnée), comme dans le scénario cabinet juridique.
  • La clé de traduction est une API. Un @@home.title qui change casse les traductions et le workflow Crowdin. On traite les IDs comme un schéma versionné : on les nomme par fonction (checkout.payment.cta), jamais par contenu, et on ne les renomme pas à la légère.
  • Observabilité i18n : logger les clés manquantes en prod (Transloco missingHandler, ou i18nMissingTranslation: "error" qui casse le build — préférable). Une clé manquante affichée brute (home.title) à un client est un incident, pas un détail cosmétique.

🏭 Concerns de prod (perf / sécurité / observabilité / a11y)

Budget de perf — où va le coût

SolutionCoût au bootCoût au switchCoût mémoire runtime
@angular/localize0 (chaînes inlinées)full reload (~TTI complet)0 dictionnaire en mémoire
Transloco (un seul JSON)1 fetch bloquant le 1er renderre-render OnPush des vues activestout le dico en RAM
Transloco (scopes lazy)1 fetch du scope racine seulementre-render + fetch du scope manquantuniquement les scopes chargés

Règle : sur runtime i18n, le JSON de boot est sur le chemin critique du LCP. Le précharger (<link rel="preload" as="fetch">) et le servir avec un hash de contenu (fr.A1B2.json) immutable. Ne jamais bloquer l'app entière sur le chargement d'un scope rarement visité — d'où les scopes lazy.

Observabilité — instrumenter le missingHandler

ts
// transloco-missing.handler.ts — remonter les clés manquantes au lieu de les avaler
import { TranslocoMissingHandler } from '@jsverse/transloco';
import { inject, Injectable } from '@angular/core';
import { Telemetry } from './telemetry';

@Injectable()
export class ProdMissingHandler implements TranslocoMissingHandler {
  private readonly telemetry = inject(Telemetry);
  handle(key: string, config: { activeLang: string }) {
    // un compteur par (clé, locale) → dashboard. Échantillonner pour ne pas noyer.
    this.telemetry.increment('i18n.missing', { key, lang: config.activeLang });
    return key; // affiche la clé brute — visible et alertable, pas silencieux
  }
}

Un taux de clés manquantes > 0 en prod est une alerte, pas un warning. Le vrai filet reste build-time (i18nMissingTranslation: "error" côté @angular/localize) : préférer casser la CI que détecter en prod.

Sécurité — la traduction est une surface d'injection

  • HTML dans le JSON ([innerHTML] + pipe) : si une traduction vient d'un CMS, d'un overlay tenant ou d'un outil collaboratif, c'est une source non-trusted. Un traducteur (ou un compte compromis) peut injecter <img onerror=...>. Sanitizer systématiquement (DomSanitizer.sanitize(SecurityContext.HTML, …)), jamais bypassSecurityTrustHtml sur de la donnée externe.
  • Interpolation de placeholders : t('greet', { name }) est sûr (texte, pas HTML). Le danger n'apparaît qu'avec [innerHTML].
  • Overlay tenant : valider le schéma des clés autorisées côté backend — un tenant ne doit pouvoir override que des clés d'une allowlist, pas injecter des clés arbitraires qui shadow des libellés de sécurité (« Déconnexion » → autre chose).

Accessibilité — lang n'est pas optionnel

L'attribut lang correct sur <html> (et sur tout fragment de langue différente) pilote la prononciation des lecteurs d'écran et la coupure de mots. Avec @angular/localize il est posé par le build ; en runtime, le mettre à jour à chaque switch (cf. snippet RTL ci-dessus). Un bloc dans une autre langue se balise localement : <blockquote lang="en">…</blockquote>.

🔄 Versions — Angular 16 → 20

  • v16$localize stable, build par locale stable. ICU expressions améliorées.
  • v17 — Application builder esbuild + localize: true : compilation par locale très rapide (parallélisée). Auparavant chaque locale prenait du temps avec webpack.
  • v18 — Hydration SSR avec i18n correctement supportée (les blocs i18n étaient parfois mal hydratés en v16-17).
  • v19 — Meilleurs warnings sur traductions manquantes. Support amélioré du XLIFF 2.0.
  • v20 — Pas de gros changement majeur côté @angular/localize. Transloco v7 (compatible Angular 20) renforce le support des signals (selectTranslate + toSignal) et le mode zoneless.

⚠️ Pitfalls — 6-10

  1. IDs auto-générés — sans @@id, Angular génère un hash basé sur le texte. Modifier la chaîne casse l'ID → toutes les traductions sont perdues. Toujours utiliser des IDs explicites.

  2. Concaténation de chaînes — séparer le label de la variable casse le contexte pour le traducteur (l'ordre des mots varie selon la langue : « 3 articles » → « 3 items » mais « Bonjour Marie » → « Hello Marie », ordre conservé, alors qu'en japonais le verbe finit la phrase). Mauvais vs bon :

    html
    <!-- ❌ deux segments séparés : le traducteur ne voit pas le contexte, l'ordre est figé -->
    <span i18n>Bonjour</span> <span>{{ name }}</span>
    
    <!-- ✅ un seul segment avec placeholder : le traducteur peut réordonner -->
    <span i18n>Bonjour {{ name }}</span>
  3. Pluralisation oubliée — interpoler le compte brut produit du texte faux pour 0 ou 1 (et dans les langues à plusieurs formes plurielles : le russe en a 4, l'arabe 6). Toujours passer par ICU plural :

    html
    <!-- ❌ -->
    <p i18n>{{ count }} items</p>
    
    <!-- ✅ -->
    <p i18n>{ count, plural, =0 {No items} =1 {1 item} other {{{ count }} items} }</p>
  4. Switch dynamique avec @angular/localize — impossible sans full reload. Si c'est requis, choisir ngx-translate ou Transloco dès le départ.

  5. Bundle taille avec ngx-translate — toutes les traductions sont chargées (par langue) au boot. Sur de grosses apps : lazy load par feature (Transloco scopes).

  6. Tests qui pètent en CI — sans LOCALE_ID configuré, les pipes date et currency ont des formats US. Configurer le testbed avec le bon locale.

  7. Cache CDN des JSON — si on cache fr.json 1 an, les utilisateurs ne reçoivent pas les nouvelles traductions. Soit hash le JSON (fr.A1B2.json), soit cache court (1 h).

  8. HTML dans les traductions — autorisé avec ngx-translate ([innerHTML] + pipe), mais danger XSS si le JSON vient d'une source non-trusted. Préférer interpolation + composants.

  9. Right-to-left (RTL) — l'arabe et l'hébreu demandent dir="rtl" sur <html>. Pas géré automatiquement par Angular. Deux pièges au-delà du dir : (a) le CSS — passer à des propriétés logiques (margin-inline-start au lieu de margin-left, padding-inline-end, inset-inline) pour que la mise en page se miroite seule ; (b) le bidi mixte — un nom propre latin (« John ») ou un nombre dans une phrase arabe doivent rester LTR : encadrer avec <bdi> ou les marques Unicode ⁨…⁩ (FSI/PDI) pour éviter le réordonnancement visuel cassé. Mettre à jour dir et l'attribut lang du <html> à chaque switch :

    ts
    // leur poser ensemble : lang pour l'a11y/SEO, dir pour le layout
    const RTL = new Set(['ar', 'he', 'fa', 'ur']);
    effect(() => {
      const lang = this.transloco.getActiveLang();
      document.documentElement.lang = lang;
      document.documentElement.dir = RTL.has(lang) ? 'rtl' : 'ltr';
    });
  10. Migration tardive — internationaliser une app en cours de route est très coûteux (revisite tous les composants, tous les services). Toujours commencer i18n-ready, même mono-locale.

🧪 Testing

ts
// Tests avec ngx-translate
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';

it('affiche le titre traduit', () => {
  TestBed.configureTestingModule({
    imports: [HomeComponent, TranslateModule.forRoot()],
  });
  const fixture = TestBed.createComponent(HomeComponent);
  fixture.detectChanges();
  expect(fixture.nativeElement.textContent).toContain('home.title'); // sans loader, retourne la clé
});

Pour @angular/localize, tester par locale avec loadTranslations :

ts
import { loadTranslations } from '@angular/localize';

loadTranslations({
  'home.title': 'Welcome to our site',
});

// Le composant rendra l'anglais.

E2E : tester chaque locale en visitant /fr/, /en/, etc., et vérifier les chaînes clés. Pour le multi-tenant overlay, faire un test E2E par tenant.

🎬 Cas d'usage concrets

Scénario 1 — E-commerce mode FR/EN/ES/IT

Contexte : retailer mode présent en France, UK, Espagne, Italie avec 4 sites localisés et 4 catalogues (prix locaux, tailles UK/EU, marketing par marché). Objectifs SEO forts : domaines/sous-dossiers par locale (shop.fr/, shop.co.uk/en/, shop.es/es/, shop.it/it/), URL traduites (/produits/ vs /products/ vs /productos/). Approche : @angular/localize pour la performance et le SEO. Un build par locale avec i18n.locales configuré dans angular.json, fichiers messages.fr.xlf, messages.en.xlf, etc. Le routing applicatif est partagé mais les paths sont traduits via une RouterModule configurée par locale. Les devises et formats utilisent les pipes Angular natifs qui détectent la locale (ex. pipe prix | currency:'EUR':'symbol':'1.2-2':locale). Le sélecteur de langue redirige vers le bon sous-domaine (full reload assumé). Le changement de locale par utilisateur est rare (les visiteurs viennent via Google sur leur locale native), donc le reload n'est pas un problème UX. Les traductions sont gérées sur Crowdin avec sync automatique vers Git.

Scénario 2 — SaaS RH FR/EN

Contexte : SaaS RH français qui s'ouvre à l'international avec une première version anglaise. Les clients sont des entreprises et il y a des utilisateurs bilingues dans la même org (équipe France + équipe UK partageant la même instance) qui doivent pouvoir changer de langue à la volée dans les préférences. Approche : Transloco pour le switch runtime. Les traductions sont organisées par feature (assets/i18n/ats/fr.json, assets/i18n/ats/en.json) chargées en lazy quand la route ATS est visitée. Le service TranslocoService est intégré aux signals via un wrapper t = toSignal(translocoService.selectTranslate(key)). La préférence utilisateur est persistée en backend (/api/me/prefs) et appliquée au boot via provideAppInitializer. Les emails transactionnels (offre envoyée, candidat refusé) sont rendus côté backend avec la même clé i18n via un fichier YAML partagé. La gestion des pluriels et du genre passe par les ICU MessageFormat de Transloco :

text
{count, plural, =0 {Aucun candidat} =1 {1 candidat} other {# candidats}}

Scénario 3 — Cabinet juridique multi-langues

Contexte : cabinet d'affaires international avec bureaux Paris, Londres, Bruxelles, Genève. Les avocats travaillent en français, anglais, néerlandais. Les documents juridiques sont rédigés dans la langue du dossier, mais l'interface app doit suivre la préférence utilisateur, indépendamment de la langue du dossier consulté. Approche hybride : Transloco pour l'UI (switch runtime via menu profil), et un champ langue: 'fr'|'en'|'nl' sur chaque dossier qui détermine la langue d'affichage des libellés métier issus du backend (statuts, types de pièces, modèles de courriers). Les dates sont formatées via DatePipe avec la locale UI (présentation user), mais les dates affichées dans un courrier généré utilisent la locale du dossier (présentation client). Cette séparation locale UI / locale donnée est clarifiée par deux InjectionToken (LOCALE_ID pour UI, DOSSIER_LOCALE scopé route). Les libellés métier sont stockés en backend sous forme { fr: 'Conclusions', en: 'Submissions', nl: 'Conclusies' } et résolus côté front via un pipe dédié.

🛠️ Exemple end-to-end

Use case : SaaS RH avec Transloco FR/EN, switch runtime, pluriels ICU, lazy par feature, et persistance préférence.

ts
// app.config.ts
import { ApplicationConfig, isDevMode, provideAppInitializer, inject } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideTransloco, TranslocoService } from '@jsverse/transloco';
import { provideTranslocoMessageformat } from '@jsverse/transloco-messageformat';
import { firstValueFrom } from 'rxjs';
import { TranslocoHttpLoader } from './transloco-http.loader';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideTransloco({
      config: {
        availableLangs: ['fr', 'en'],
        defaultLang: 'fr',
        fallbackLang: 'fr',
        reRenderOnLangChange: true,
        prodMode: !isDevMode(),
      },
      loader: TranslocoHttpLoader,
    }),
    provideTranslocoMessageformat(),
    provideAppInitializer(() => {
      const ts = inject(TranslocoService);
      const lang = localStorage.getItem('lang') || navigator.language.split('-')[0] || 'fr';
      const supported = ['fr', 'en'].includes(lang) ? lang : 'fr';
      return firstValueFrom(ts.load(supported)).then(() => ts.setActiveLang(supported));
    }),
  ],
};
ts
// transloco-http.loader.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Translation, TranslocoLoader } from '@jsverse/transloco';

@Injectable({ providedIn: 'root' })
export class TranslocoHttpLoader implements TranslocoLoader {
  private http = inject(HttpClient);
  getTranslation(lang: string) {
    return this.http.get<Translation>(`/assets/i18n/${lang}.json`);
  }
}
json
// assets/i18n/fr.json
{
  "ats": {
    "title": "Suivi des candidatures",
    "candidates": "{count, plural, =0 {Aucun candidat} =1 {1 candidat} other {# candidats}}",
    "stage": {
      "new": "Nouveau",
      "screen": "Présélection",
      "interview": "Entretien",
      "offer": "Offre",
      "hired": "Embauché",
      "rejected": "Refusé"
    },
    "actions": {
      "move": "Déplacer vers {stage}",
      "send_offer": "Envoyer l'offre"
    }
  },
  "common": {
    "save": "Enregistrer",
    "cancel": "Annuler",
    "language": "Langue de l'interface"
  }
}
json
// assets/i18n/en.json
{
  "ats": {
    "title": "Applicant tracking",
    "candidates": "{count, plural, =0 {No candidates} =1 {1 candidate} other {# candidates}}",
    "stage": {
      "new": "New",
      "screen": "Screening",
      "interview": "Interview",
      "offer": "Offer",
      "hired": "Hired",
      "rejected": "Rejected"
    },
    "actions": {
      "move": "Move to {stage}",
      "send_offer": "Send offer"
    }
  },
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "language": "Interface language"
  }
}
ts
// pipeline.component.ts
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { TranslocoDirective, TranslocoPipe } from '@jsverse/transloco';
import { Candidat } from './candidat.model';

@Component({
  selector: 'app-pipeline',
  imports: [TranslocoDirective, TranslocoPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <ng-container *transloco="let t; prefix: 'ats'">
      <h1>{{ t('title') }}</h1>
      <p>{{ t('candidates', { count: candidats().length }) }}</p>

      @for (c of candidats(); track c.id) {
        <article>
          <h2>{{ c.nom }}</h2>
          <span class="stage">{{ t('stage.' + c.stage) }}</span>
          <button>{{ t('actions.move', { stage: t('stage.interview') }) }}</button>
        </article>
      }
    </ng-container>
  `,
})
export class PipelineComponent {
  readonly candidats = input.required<ReadonlyArray<Candidat>>();
}
ts
// language-switcher.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TranslocoService } from '@jsverse/transloco';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-language-switcher',
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <label>
      Langue :
      <select [value]="current()" (change)="switch($any($event.target).value)">
        <option value="fr">Français</option>
        <option value="en">English</option>
      </select>
    </label>
  `,
})
export class LanguageSwitcherComponent {
  private readonly ts = inject(TranslocoService);
  protected current = () => this.ts.getActiveLang();

  switch(lang: string) {
    this.ts.setActiveLang(lang);
    localStorage.setItem('lang', lang);
  }
}

Switch runtime instantané, traductions lazy par feature, pluriels ICU corrects en FR et EN, préférence persistée, prêt pour ajout futur d'autres langues.

🔁 Quand utiliser / éviter

Utiliser @angular/localize :

  • site public, audience grand public ;
  • nombre de locales connu et stable ;
  • pas besoin de switch dynamique ;
  • SEO important (hreflang + URLs distinctes).

Utiliser ngx-translate :

  • legacy à maintenir ;
  • équipe déjà familière ;
  • switch dynamique requis.

Utiliser Transloco :

  • nouveau projet avec besoins runtime (switch dynamique, multi-tenant) ;
  • besoin de signals, lazy load par scope, SSR-friendly ;
  • préférence pour API moderne et activement maintenue.

Éviter de mélanger les deux solutions dans la même app — sauf migration progressive bien planifiée.

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Stack de référence : Angular 20 standalone + zoneless, Transloco v7, ou @angular/localize selon l'exo.

Exercice 1 — ICU plural correct multi-langue

Objectif : afficher « X candidat(s) » correct en FR, EN, et russe (4 formes plurielles).

Implémenter une clé ats.candidates qui rend juste pour count ∈ {0, 1, 2, 5, 21} dans les 3 langues. Tester que le russe distingue один (1, 21), кандидата (2), кандидатов (5).

Indice / Solution

Avec transloco-messageformat, ICU gère les catégories CLDR automatiquement — ne pas écrire =2/=5 mais les catégories one/few/many/other. Le runtime choisit la bonne via les règles CLDR de la locale active.

json
// ru.json
{ "ats": { "candidates": "{count, plural, =0 {Нет кандидатов} one {# кандидат} few {# кандидата} many {# кандидатов} other {# кандидата}}" } }

Ne jamais coder =2, =5 en dur : ça marche en russe par accident mais casse en arabe (6 formes) ou japonais (1 forme).

Exercice 2 — Lazy scope + préchargement intelligent

Objectif : charger les traductions d'une feature checkout en lazy, sans flash de clés brutes (checkout.title) au premier rendu.

Configurer un provideTranslocoScope('checkout') sur le composant, puis garantir que le JSON est résolu avant que le template ne rende.

Indice / Solution

Le flash vient du rendu avant résolution. Deux leviers : (a) *transloco attend le scope (le bloc ne rend pas tant que la traduction n'est pas là) ; (b) précharger le scope dans un resolver de route ou via transloco.load('checkout/' + lang) dans le guard, pour que la navigation n'affiche jamais d'état intermédiaire. Mesurer : un console.count dans le loader doit montrer 1 fetch, pas un par token de clé.

Exercice 3 — Multi-tenant overlay sans data race

Objectif : rendre production-grade le pattern d'overlay tenant (section Patterns #3), qui a un bug subtil.

Le { ...base, ...override } du code existant est un merge shallow : il écrase home entier si le tenant override une seule clé home.cta. Corriger pour un deep-merge, et gérer le cas où le fetch tenant échoue (timeout) sans bloquer le boot.

Indice / Solution

Spread shallow → un override de { home: { cta: 'X' } } supprime home.title. Faire un deep-merge (lodash merge, ou récursif maison). Pour le fetch tenant : Promise.race avec un timeout + .catch(() => ({})) pour fallback gracieux sur le base. Et cache-buster le JSON tenant (hash ou ?v=) sinon un override mis à jour reste invisible derrière le CDN.

Exercice 4 — Casser puis réparer : IDs auto-générés

Objectif : reproduire la perte de traductions due aux IDs implicites, puis blinder le build contre ça.

Avec @angular/localize, marquer 3 chaînes sans @@id. Traduire en EN. Puis modifier une chaîne source (corriger une typo). Extraire à nouveau → constater que la traduction EN de cette chaîne est perdue (nouvel ID hashé). Réparer : IDs explicites + activer i18nMissingTranslation: "error" pour que le build échoue au lieu de servir du FR sur le site EN.

Indice / Solution

L'ID implicite = hash(texte + meaning + description). Toucher le texte → nouvel ID → l'unité XLIFF traduite devient orpheline. Fix : i18n="@@home.title". Filet de sécurité CI : i18nMissingTranslation: "error" dans angular.json (et i18nDuplicateTranslation: "error"). Un lint custom peut interdire tout i18n= sans @@.

Exercice 5 — Stop button qui annule client ET serveur (AI)

Objectif : sur un chat agent streamé, le bouton Stop doit couper le rendu et stopper l'appel LLM côté serveur (sinon on paie les tokens jusqu'au bout).

Câbler AbortController côté client (section #7). Vérifier côté backend (NestJS) que la déconnexion HTTP propage un AbortSignal au SDK Anthropic et arrête la génération claude-sonnet-4-6.

Indice / Solution

Côté client : controller.abort() ferme le ReadableStream. Côté NestJS : écouter req.on('close') (ou l'AbortSignal du framework) et passer ce signal à client.messages.stream({...}, { signal }) du SDK Anthropic — le SDK annule la requête HTTP upstream. Sans ça, le client a beau couper l'UI, la facturation continue. Tester : console.log côté serveur sur close, vérifier qu'il déclenche, et que les tokens cessent.

Exercice 6 — Bench build-time vs runtime

Objectif : prouver par la mesure pourquoi @angular/localize est plus rapide au runtime.

Builder la même app en @angular/localize (3 locales) et en Transloco. Comparer : taille du bundle initial transféré, nombre de requêtes réseau au boot, Time-To-Interactive sur réseau 3G throttlé (DevTools), et coût d'un switch de langue.

Indice / Solution

Attendu : localize → bundle plus petit, 0 fetch JSON, TTI plus rapide, mais switch = full reload (coût élevé, rare). Transloco → +1 fetch JSON au boot (bloquant si pas géré), switch quasi-gratuit. La conclusion n'est pas « X gagne » mais « le coût se déplace » : localize paie au switch, Transloco paie au boot. Le bon choix dépend de la fréquence des switchs dans ta session utilisateur réelle.

Exercice 7 — Casser puis réparer : mismatch d'hydratation SSR

Objectif : reproduire un hydration mismatch i18n en SSR runtime, puis le rendre déterministe.

App Transloco + provideClientHydration(). Faire résoudre la locale côté serveur depuis l'Accept-Language et côté client depuis localStorage. Quand les deux diffèrent (header en, storage fr), observer le warning d'hydratation / le flash de texte. Réparer : une seule source de vérité transmise du serveur au client (cookie ou state transfer), résolue avant le premier render des deux côtés.

Indice / Solution

Le mismatch vient de deux résolutions indépendantes. Le serveur sérialise le HTML en en, le client décide fr avant l'hydratation → Angular voit un DOM qui ne correspond pas à ce qu'il recalcule. Fix : poser un cookie lang côté serveur (ou utiliser TransferState), et faire en sorte que le provideAppInitializer lise la même valeur sur les deux runtimes. Règle générale : en SSR, toute donnée qui influence le premier render doit voyager du serveur au client, jamais être re-dérivée indépendamment.

Exercice 8 — RTL production-grade (layout + bidi mixte)

Objectif : ajouter l'arabe (ar) à une UI LTR existante sans casser la mise en page ni le bidi.

Switcher en ar doit : poser dir="rtl" + lang="ar", miroiter le layout, et afficher correctement une phrase mixte (« Bonjour ‏John‎, vous avez 3 messages » avec nom latin + chiffre dans un contexte RTL).

Indice / Solution

Layout : remplacer les propriétés physiques (margin-left, text-align: left, left: 0) par leurs équivalents logiques (margin-inline-start, text-align: start, inset-inline-start) — le miroir devient automatique. Bidi mixte : entourer les segments LTR (noms latins, nombres) de <bdi>…</bdi> ou des isolats Unicode FSI/PDI, sinon le moteur bidi réordonne visuellement « John » et casse la lecture. Tester avec un vrai lecteur d'écran : lang="ar" doit déclencher la voix arabe, <bdi>John</bdi> rester prononcé en latin.

🎤 En entretien

Q : Pourquoi @angular/localize ne permet pas de changer de langue sans reload ? Parce que la résolution est faite au build : les chaînes sont inlinées dans un bundle par locale (/fr/main.js, /en/main.js). Il n'y a aucun dictionnaire en mémoire au runtime à substituer — changer de langue = charger un autre bundle, donc un autre document. C'est le prix de l'overhead runtime nul.

Q : Un dev modifie une chaîne traduite et toutes les traductions de cette chaîne disparaissent en prod. Diagnostic ? IDs auto-générés. Sans @@id explicite, Angular hashe le texte source pour l'ID ; modifier le texte change le hash, l'unité XLIFF traduite devient orpheline, la nouvelle est non traduite. Fix : IDs explicites partout + i18nMissingTranslation: "error" pour que le build échoue plutôt que de servir la mauvaise langue silencieusement.

Q : Comment internationaliser une UI de chat LLM ? On sépare deux mondes. La coquille (boutons, statuts, timeline d'outils) est de l'i18n classique (Transloco). Le contenu généré n'est jamais mis en fichier de traduction — pas de clé stable, change à chaque génération ; on passe la locale active comme paramètre du system prompt pour que le modèle réponde directement dans la bonne langue. Frontière nette : chrome statique → i18n ; texte du modèle → locale-as-prompt-param.

Q : Locale UI vs locale donnée — donne un cas où les confondre est un bug. App juridique : un avocat anglophone (UI en EN) consulte un dossier français. La date d'audience dans un courrier généré pour le client doit être formatée en fr-FR (locale du dossier), pas en en-US (locale UI). Confondre les deux envoie un document mal formaté au destinataire. Solution : deux tokens distincts — LOCALE_ID pour l'UI, un token scopé route pour la donnée.

Q : Tu choisis entre @angular/localize et Transloco. Quelle est la vraie question à poser, et pourquoi le bundle n'est pas le critère #1 ? La vraie question : « l'utilisateur change-t-il de langue pendant sa session ? ». Si non (visiteur SEO arrivant sur sa locale native) → build-time, le full reload au switch est rare et acceptable, et on gagne perf + SEO gratuits. Si oui (utilisateur authentifié bilingue, multi-tenant) → runtime obligatoire. Le bundle n'est pas le critère #1 parce que le coût ne disparaît pas, il se déplace : localize paie au switch (reload), runtime paie au boot (fetch JSON sur le chemin critique du LCP). On optimise pour le pattern d'usage réel, pas pour une métrique isolée.

Q : i18n + SSR/hydration — quel est le piège, et qu'est-ce qui l'a réglé ? Avant Angular 18, les blocs i18n SSR pouvaient être mal hydratés : le serveur rendait le markup traduit (avec des marqueurs ICU), le client réhydratait et parfois ré-écrasait/dupliquait le contenu, provoquant un flash ou une erreur d'hydratation. v18 a corrigé l'hydratation des blocs i18n. Côté architecture : avec @angular/localize, le serveur sert déjà le bon bundle par locale (/fr/), donc serveur et client partagent la même locale figée — pas de désynchro possible. Avec un runtime i18n + SSR, il faut garantir que la locale résolue côté serveur (depuis l'URL/header/cookie) est identique à celle du client au moment de l'hydratation, sinon mismatch.

🔗 Liens

  • angular.dev/guide/i18n — guide officiel @angular/localize
  • ngx-translate.org
  • jsverse.github.io/transloco
  • Crowdin / Lokalise / Phrase — plateformes de traduction
  • cldr.unicode.org — données de localisation Unicode (sous-jacent à Angular)
  • Article « Angular i18n: build vs runtime » — par Olivier Combe

Bibliothèque tech perso — Achref