5. Internationalisation
Une application internationalisée affiche ses textes dans la langue de l'utilisateur. L'étude de cas est en français et en anglais. Le principe est simple : les vues ne contiennent plus de textes, mais des clés ([MENU.ACCUEIL]), et un dictionnaire par langue associe à chaque clé son texte. La langue choisie est mémorisée dans un cookie.
5.1. Exemple 22 : une application en deux langues
| 22-i18n/
├── i18n/
│ ├── en.json
│ └── fr.json
├── src/
│ ├── app.module.ts
│ ├── helpers.ts
│ ├── i18n.ts
│ ├── langue.controller.ts
│ ├── main.ts
│ └── pages.controller.ts
├── views/
│ ├── partials/
│ │ └── navbar.hbs
│ ├── accueil.hbs
│ ├── layout.hbs
│ └── produits.hbs
├── nest-cli.json
└── tsconfig.json
|
5.1.1. Les dictionnaires
Le fichier [i18n/fr.json] :
| {
"MENU": {
"ACCUEIL": "Accueil",
"PRODUITS": "Produits"
},
"ACCUEIL": {
"TITRE": "Bienvenue",
"BONJOUR": "Bonjour {nom} !",
"DATE": "Nous sommes le {date}."
},
"PRODUITS": {
"TITRE": "Nos produits",
"NOM": "Produit",
"PRIX": "Prix",
"NOMBRE": "{n} produit(s) au catalogue."
}
}
|
Le fichier [i18n/en.json] :
| {
"MENU": {
"ACCUEIL": "Home",
"PRODUITS": "Products"
},
"ACCUEIL": {
"TITRE": "Welcome",
"BONJOUR": "Hello {nom}!",
"DATE": "Today is {date}."
},
"PRODUITS": {
"TITRE": "Our products",
"NOM": "Product",
"PRIX": "Price",
"NOMBRE": "{n} product(s) in the catalogue."
}
}
|
- un dictionnaire est un fichier JSON, organisé par page ou par thème : la clé [ACCUEIL.BONJOUR] désigne le texte [BONJOUR] de la section [ACCUEIL] ;
- ligne 8 : un texte peut contenir des paramètres, entre accolades, remplacés au moment de la traduction. L'ordre des mots change d'une langue à l'autre : c'est pourquoi on ne fabrique pas un texte en concaténant des morceaux traduits.
5.1.2. Le module de traduction
Le fichier [src/i18n.ts] :
| import { readFileSync } from 'fs';
import { join } from 'path';
export type Langue = 'fr' | 'en';
export const LANGUES: Langue[] = ['fr', 'en'];
export const LANGUE_PAR_DEFAUT: Langue = 'fr';
// la "locale" utilisée par Intl pour les dates et les nombres
export const LOCALES: Record<Langue, string> = { fr: 'fr-FR', en: 'en-US' };
// un dictionnaire est un objet JSON imbriqué : { "MENU": { "ACCUEIL": "Accueil" } }
type Dictionnaire = { [cle: string]: string | Dictionnaire };
// les dictionnaires sont lus UNE fois, au démarrage
const DOSSIER = join(__dirname, '..', 'i18n');
const dictionnaires: Record<Langue, Dictionnaire> = {
fr: JSON.parse(readFileSync(join(DOSSIER, 'fr.json'), 'utf-8')),
en: JSON.parse(readFileSync(join(DOSSIER, 'en.json'), 'utf-8')),
};
export function estUneLangue(valeur: unknown): valeur is Langue {
return typeof valeur === 'string' && (LANGUES as string[]).includes(valeur);
}
// traduire('fr', 'ACCUEIL.BONJOUR', { nom: 'Marie' }) -> "Bonjour Marie !"
export function traduire(langue: Langue, cle: string, params: Record<string, unknown> = {}): string {
const texte = chercher(dictionnaires[langue], cle) ?? cle;
return texte.replace(/\{(\w+)\}/g, (tout, nom: string) => (nom in params ? String(params[nom]) : tout));
}
// 'ACCUEIL.BONJOUR' : on descend dans ACCUEIL, puis dans BONJOUR
function chercher(dictionnaire: Dictionnaire, cle: string): string | null {
let noeud: string | Dictionnaire | undefined = dictionnaire;
for (const morceau of cle.split('.')) {
if (typeof noeud !== 'object') {
return null;
}
noeud = noeud[morceau];
}
return typeof noeud === 'string' ? noeud : null;
}
|
Commentons ce code :
- lignes 4-6 : les langues gérées ;
- ligne 8 : la « locale » de chaque langue, utilisée par [Intl] pour formater les dates et les nombres ([fr-FR] : 23/09/2026, 12,50 € ; [en-US] : 9/23/2026, €12.50) ;
- lignes 14-18 : les dictionnaires sont lus une seule fois, au chargement du module, puis gardés en mémoire ;
- lignes 20-22 : une garde de type [TypeScript] ([valeur is Langue]) : après un test positif, le compilateur sait que la valeur est une [Langue] ;
- lignes 25-28 : la traduction. La clé est cherchée dans le dictionnaire ; absente, c'est la clé elle-même qui est affichée : un oubli se repère immédiatement à l'écran. L'expression régulière [/\{(\w+)\}/g] trouve chaque paramètre [{nom}] et le remplace par sa valeur ;
- lignes 31-40 : la clé [ACCUEIL.BONJOUR] est découpée en [ACCUEIL] puis [BONJOUR], et l'on descend dans l'objet JSON.
5.1.3. Les helpers
Le fichier [src/helpers.ts] :
| import { HelperOptions } from 'handlebars';
import { Langue, LANGUE_PAR_DEFAUT, LOCALES, estUneLangue, traduire } from './i18n';
interface Hbs {
registerHelper(nom: string, fonction: (...args: any[]) => unknown): void;
}
// la langue de la page : res.locals.lang, fusionné dans le modèle racine
function langueDe(options: HelperOptions): Langue {
const lang = options.data?.root?.lang;
return estUneLangue(lang) ? lang : LANGUE_PAR_DEFAUT;
}
export function enregistrerHelpers(hbs: Hbs): void {
// {{t 'CLE'}} ou {{t 'CLE' nom=...}} : traduction, avec paramètres nommés
hbs.registerHelper('t', (cle: string, options: HelperOptions) => traduire(langueDe(options), cle, options.hash));
// {{dateLongue quand}} : "mercredi 23 septembre 2026" / "Wednesday, September 23, 2026"
hbs.registerHelper('dateLongue', (quand: string, options: HelperOptions) =>
new Intl.DateTimeFormat(LOCALES[langueDe(options)], { dateStyle: 'full' }).format(new Date(quand)),
);
// {{monnaie prix}} : "12,50 €" / "€12.50"
hbs.registerHelper('monnaie', (montant: number, options: HelperOptions) =>
new Intl.NumberFormat(LOCALES[langueDe(options)], { style: 'currency', currency: 'EUR' }).format(montant),
);
hbs.registerHelper('eq', (a: unknown, b: unknown) => a === b);
}
|
- lignes 9-12 : la langue de la page est dans le modèle racine ([options.data.root.lang]), où le middleware de [main.ts] l'a placée via [res.locals] ;
- ligne 16 : le helper [t] traduit une clé. Les paramètres nommés du helper ([options.hash]) sont les paramètres du texte : [{{t 'ACCUEIL.BONJOUR' nom=nom}}] ;
- lignes 19-21 et 24-26 : dates et montants sont formatés selon la langue.
5.1.4. Le choix de la langue
Le fichier [src/main.ts] :
| import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express';
import * as hbs from 'hbs';
import { dirname, join } from 'path';
import { AppModule } from './app.module';
import { enregistrerHelpers } from './helpers';
import { estUneLangue, LANGUE_PAR_DEFAUT } from './i18n';
const RACINE = join(__dirname, '..');
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useStaticAssets(join(dirname(require.resolve('bootstrap/package.json')), 'dist'), { prefix: '/bootstrap/' });
app.setBaseViewsDir(join(RACINE, 'views'));
app.setViewEngine('hbs');
enregistrerHelpers(hbs);
await new Promise<void>((fini) => hbs.registerPartials(join(RACINE, 'views', 'partials'), () => fini()));
app.use(cookieParser());
// la langue de chaque page : celle du cookie [langue], le français sinon
app.use((req: Request, res: Response, next: NextFunction) => {
const cookie = req.cookies?.langue;
res.locals.lang = estUneLangue(cookie) ? cookie : LANGUE_PAR_DEFAUT;
next();
});
await app.listen(3000);
console.log('Serveur démarré sur http://localhost:3000');
}
bootstrap();
|
lignes 22-26 : à chaque requête, la langue est lue dans le cookie [langue] (le français par défaut) et placée dans [res.locals.lang], donc dans le modèle de chaque vue.
Le fichier [src/langue.controller.ts] :
| import { Controller, Get, NotFoundException, Param, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { estUneLangue } from './i18n';
@Controller('langue')
export class LangueController {
// GET /langue/en : mémorise le choix dans un cookie, puis revient à la page précédente
@Get(':langue')
changer(@Param('langue') langue: string, @Req() req: Request, @Res() res: Response): void {
if (!estUneLangue(langue)) {
throw new NotFoundException();
}
res.cookie('langue', langue, { maxAge: 365 * 24 * 3600 * 1000, sameSite: 'lax', httpOnly: true });
res.redirect(pageDOrigine(req));
}
}
// la page d'où l'on vient (entête Referer), dont on ne garde que le chemin :
// rediriger vers une adresse complète venue de la requête permettrait
// d'envoyer l'utilisateur sur un autre site (faille "open redirect")
function pageDOrigine(req: Request): string {
const referer = req.get('referer');
if (!referer) {
return '/';
}
try {
const url = new URL(referer);
return url.pathname + url.search;
} catch {
return '/';
}
}
|
- lignes 8-15 : [GET /langue/en] mémorise le choix dans un cookie valable un an, puis redirige vers la page d'où l'on vient. Une langue inconnue provoque une 404 ;
- lignes 21-32 : la page d'origine est donnée par l'entête [Referer], que le navigateur ajoute quand on clique sur un lien. On n'en garde que le chemin : rediriger vers une adresse complète venue de la requête permettrait à un site malveillant de fabriquer un lien qui, après être passé par notre site, envoie l'utilisateur chez lui (faille dite [open redirect]).
5.1.5. Les vues
Le fichier [views/layout.hbs] :
| <!doctype html>
<html lang="{{lang}}">
<head>
<meta charset="utf-8">
<title>{{t titrePage}}</title>
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
</head>
<body>
<div class="container py-3">
{{> navbar}}
{{{body}}}
</div>
</body>
</html>
|
- ligne 2 : l'attribut [lang] de la page suit la langue choisie (utile aux lecteurs d'écran et aux correcteurs orthographiques) ;
- ligne 5 : le titre de la page est une clé, fournie par l'action ([titrePage]), et traduite par la vue : [{{t titrePage}}]. Sans apostrophes, l'argument du helper est une variable du modèle, et non une chaîne littérale.
Le fichier [views/partials/navbar.hbs] :
| <nav class="navbar navbar-expand bg-light rounded mb-3 px-3">
<div class="navbar-nav me-auto">
<a class="nav-link" href="/">{{t 'MENU.ACCUEIL'}}</a>
<a class="nav-link" href="/produits">{{t 'MENU.PRODUITS'}}</a>
</div>
{{!-- la langue courante est mise en évidence --}}
<div class="btn-group btn-group-sm">
<a class="btn {{#if (eq lang 'fr')}}btn-primary{{else}}btn-outline-primary{{/if}}" href="/langue/fr">FR</a>
<a class="btn {{#if (eq lang 'en')}}btn-primary{{else}}btn-outline-primary{{/if}}" href="/langue/en">EN</a>
</div>
</nav>
|
lignes 8-9 : deux liens (changer de langue ne modifie aucune donnée de l'application : un GET convient), la langue courante étant mise en évidence.
Le fichier [views/accueil.hbs] :
| <h1>{{t 'ACCUEIL.TITRE'}}</h1>
<p>{{t 'ACCUEIL.BONJOUR' nom=nom}}</p>
<p>{{t 'ACCUEIL.DATE' date=(dateLongue aujourdhui)}}</p>
|
ligne 3 : une sous-expression : la date est d'abord formatée par [dateLongue], puis passée comme paramètre [date] au helper [t].
Le fichier [views/produits.hbs] :
| <h1>{{t 'PRODUITS.TITRE'}}</h1>
<table class="table table-sm w-auto">
<thead><tr><th>{{t 'PRODUITS.NOM'}}</th><th class="text-end">{{t 'PRODUITS.PRIX'}}</th></tr></thead>
<tbody>
{{#each produits}}
{{!-- dans la boucle, le helper retrouve la langue dans le modèle RACINE --}}
<tr><td>{{nom}}</td><td class="text-end">{{monnaie prix}}</td></tr>
{{/each}}
</tbody>
</table>
<p>{{t 'PRODUITS.NOMBRE' n=nombre}}</p>
|
Le fichier [src/pages.controller.ts] :
| import { Controller, Get, Render } from '@nestjs/common';
@Controller()
export class PagesController {
@Get()
@Render('accueil')
accueil(): object {
// titrePage est une CLÉ : la vue la traduit
return { titrePage: 'ACCUEIL.TITRE', nom: 'Marie', aujourdhui: '2026-09-23' };
}
@Get('produits')
@Render('produits')
produits(): object {
const produits = [
{ nom: 'Clavier', prix: 25 },
{ nom: 'Souris', prix: 12.5 },
{ nom: 'Écran', prix: 1149 },
];
return { titrePage: 'PRODUITS.TITRE', produits, nombre: produits.length };
}
}
|
5.1.6. Exécution
En français (pas encore de cookie) :


Un clic sur EN :
| curl -i -H "Referer: http://localhost:3000/produits" http://localhost:3000/langue/en
|
| HTTP/1.1 302 Found
Set-Cookie: langue=en; Max-Age=31536000; Path=/; Expires=Thu, 23 Sep 2027 14:16:03 GMT; HttpOnly; SameSite=Lax
Location: /produits
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Found. Redirecting to /produits
|
Le serveur dépose le cookie et renvoie à la page d'origine, désormais en anglais :


L'étude de cas procède exactement ainsi, avec des dictionnaires plus fournis ([i18n/fr.json] et [i18n/en.json]) : même helper [t], même contrôleur [/langue/:langue], même cookie. Une seule différence : les messages d'erreur produits par les couches métier et web sont eux aussi des clés, traduites au dernier moment par la vue.