Internationalisation (i18n) avec nestjs-i18n
TL;DR —
nestjs-i18nfournit un module complet pour la détection de langue (header, query, cookie, JWT), des traductions ICU MessageFormat (plurals, gender, currency), du fallback, du lazy loading, et l'intégration avecclass-validatorpour traduire les erreurs. Pour atteindre un niveau ninja, il faut maîtriser laI18nContext, lesresolvers, lesformatters, la stratégie de synchronisation avec le frontend et la prévention des clés manquantes via des scripts CI.
🧠 Mental model — diagramme ASCII + analogie
Imaginez nestjs-i18n comme un commutateur téléphonique multilingue : chaque requête entrante passe par une chaîne de résolveurs qui inspectent header, query, cookie, JWT, jusqu'à trouver une langue valide. Une fois la langue déterminée, elle est attachée à un AsyncLocalStorage (la I18nContext) accessible depuis n'importe où dans la pipeline.
┌──────────────────────────────────────────────────────────┐
HTTP Req ─► │ 1. HeaderResolver (Accept-Language: fr-FR,en;q=0.8) │
│ 2. QueryResolver (?lang=fr) │
│ 3. CookieResolver (lang=fr) │
│ 4. JwtResolver (payload.locale) │
│ 5. AcceptLanguageResolver (RFC 7231 negotiation) │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────┐
│ I18nContext (ALS) │
│ lang = "fr" │
│ fallback = "en" │
└──────────────────────────┘
│
┌─────────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Controllers │ │ Validation pipes │ │ Exception filters │
│ i18n.t('key') │ │ @IsEmail({message}) │ │ HttpException → t() │
└──────────────────┘ └─────────────────────┘ └─────────────────────┘
│
▼
┌──────────────────────────┐
│ ICU MessageFormat engine │
│ {count, plural, one{...}}│
└──────────────────────────┘Analogie : un sommelier qui adapte ses recommandations selon la nationalité du client. La sommellerie (le resolver chain) interroge le client (la requête), puis tire la bouteille de la bonne cave (le fichier JSON fr.json), et si la cuvée demandée n'existe pas, il sert la cuvée maison (le fallback).
🛠️ Code minimal (ts)
// src/i18n/i18n.module.ts
import { Module } from '@nestjs/common';
import {
AcceptLanguageResolver,
CookieResolver,
HeaderResolver,
I18nJsonLoader,
I18nModule,
QueryResolver,
} from 'nestjs-i18n';
import * as path from 'node:path';
import { JwtI18nResolver } from './jwt-i18n.resolver';
@Module({
imports: [
I18nModule.forRoot({
fallbackLanguage: 'en',
fallbacks: {
'fr-*': 'fr',
'en-*': 'en',
'pt-BR': 'pt',
},
loaderOptions: {
path: path.join(__dirname, '../../i18n/'),
watch: process.env.NODE_ENV !== 'production',
includeSubfolders: true,
},
loader: I18nJsonLoader,
resolvers: [
{ use: QueryResolver, options: ['lang', 'locale'] },
new HeaderResolver(['x-custom-lang']),
new CookieResolver(['lang']),
JwtI18nResolver,
AcceptLanguageResolver,
],
typesOutputPath: path.join(
process.cwd(),
'src/generated/i18n.generated.ts',
),
}),
],
})
export class AppI18nModule {}// src/i18n/jwt-i18n.resolver.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { I18nResolver } from 'nestjs-i18n';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class JwtI18nResolver implements I18nResolver {
constructor(private readonly jwt: JwtService) {}
async resolve(context: ExecutionContext): Promise<string | undefined> {
const req = context.switchToHttp().getRequest();
const auth = req.headers?.authorization;
if (!auth?.startsWith('Bearer ')) return undefined;
try {
const payload = await this.jwt.verifyAsync(auth.slice(7));
return payload?.locale;
} catch {
return undefined;
}
}
}// i18n/fr/common.json
{
"welcome": "Bienvenue {name}",
"items": "{count, plural, =0 {aucun élément} one {un élément} other {# éléments}}",
"price": "Prix : {amount, number, ::currency/EUR}",
"validation": {
"email": "Adresse email invalide",
"minLength": "Le champ doit contenir au moins {min} caractères"
},
"errors": {
"notFound": "Ressource introuvable",
"forbidden": "Action interdite"
}
}// i18n/en/common.json
{
"welcome": "Welcome {name}",
"items": "{count, plural, =0 {no items} one {one item} other {# items}}",
"price": "Price: {amount, number, ::currency/USD}",
"validation": {
"email": "Invalid email address",
"minLength": "Field must contain at least {min} characters"
},
"errors": {
"notFound": "Resource not found",
"forbidden": "Action forbidden"
}
}// src/cart/cart.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { I18n, I18nContext } from 'nestjs-i18n';
import { I18nTranslations } from '../generated/i18n.generated';
@Controller('cart')
export class CartController {
@Get(':id/summary')
summary(
@Param('id') id: string,
@I18n() i18n: I18nContext<I18nTranslations>,
) {
return {
title: i18n.t('common.welcome', { args: { name: 'Alice' } }),
lines: i18n.t('common.items', { args: { count: 3 } }),
total: i18n.t('common.price', { args: { amount: 42.5 } }),
};
}
}// src/dto/create-user.dto.ts
import { IsEmail, MinLength } from 'class-validator';
import { i18nValidationMessage } from 'nestjs-i18n';
import { I18nTranslations } from '../generated/i18n.generated';
export class CreateUserDto {
@IsEmail(
{},
{ message: i18nValidationMessage<I18nTranslations>('common.validation.email') },
)
email!: string;
@MinLength(8, {
message: i18nValidationMessage<I18nTranslations>(
'common.validation.minLength',
),
})
password!: string;
}// src/main.ts
import { NestFactory } from '@nestjs/core';
import { I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new I18nValidationPipe());
app.useGlobalFilters(
new I18nValidationExceptionFilter({ detailedErrors: false }),
);
await app.listen(3000);
}
bootstrap();🎯 Patterns courants — 3-6 patterns
1. Traduction d'exceptions HTTP
Au lieu de lancer throw new NotFoundException('User not found'), on lance une exception dont le message est une clé de traduction, puis un filtre la résout côté sortie. Cela évite les chaînes en dur dans la couche métier.
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { I18nContext } from 'nestjs-i18n';
@Catch(HttpException)
export class TranslatedHttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse();
const i18n = I18nContext.current();
const status = exception.getStatus();
const key = exception.message; // ex: "errors.notFound"
const message = i18n ? i18n.t(key) : key;
res.status(status).json({ statusCode: status, message });
}
}Dans le service : throw new NotFoundException('common.errors.notFound'). Le payload sorti sera traduit selon la langue détectée.
2. Lazy loading de namespaces
Charger toutes les locales au démarrage devient coûteux quand l'application a 20 langues et 50 namespaces. La parade : I18nJsonLoader avec includeSubfolders: true et un découpage par feature (i18n/fr/billing.json, i18n/fr/auth.json). On peut aussi écrire un loader custom qui lit depuis Redis ou S3 et met en cache LRU.
import { I18nLoader } from 'nestjs-i18n';
@Injectable()
export class RemoteI18nLoader extends I18nLoader {
private cache = new Map<string, Record<string, unknown>>();
async languages(): Promise<string[]> {
return ['en', 'fr', 'es', 'de'];
}
async load(): Promise<Record<string, Record<string, unknown>>> {
const langs = await this.languages();
const out: Record<string, Record<string, unknown>> = {};
for (const l of langs) {
if (!this.cache.has(l)) {
const remote = await fetch(`https://cdn.example.com/i18n/${l}.json`);
this.cache.set(l, await remote.json());
}
out[l] = this.cache.get(l)!;
}
return out;
}
}3. Pluralisation ICU contextuelle
L'ICU MessageFormat gère plural, selectordinal, select (gender), number, date. Cela évite les pièges des langues slaves (russe, polonais) qui ont 3-4 formes plurielles.
{
"notifications": "{count, plural, =0 {Aucune notification} one {# notification non lue} other {# notifications non lues}}",
"gender": "{gender, select, male {Bienvenue Monsieur} female {Bienvenue Madame} other {Bienvenue}}"
}Côté client mobile, on peut ré-utiliser ces fichiers JSON avec formatjs/intl-messageformat pour garantir la cohérence de rendu.
4. Fallback chain par tags BCP 47
Quand on supporte fr, fr-CA, fr-CH, on définit des règles : si la clé manque en fr-CA, on remonte vers fr, puis vers en (fallback global). C'est exactement ce que fait fallbacks: { 'fr-*': 'fr' }. Couplé à AcceptLanguageResolver, on respecte la RFC 7231 et le quality value.
5. Génération de types TypeScript
typesOutputPath génère un fichier i18n.generated.ts contenant l'union de toutes les clés. En utilisant i18n.t<I18nTranslations>('common.welcome'), on obtient une autocomplétion exhaustive et une erreur de compilation si une clé est supprimée. Combiner avec --watch en dev pour régénérer à chaque modification.
6. Intégration Swagger / OpenAPI
Swagger affiche les descriptions de DTO en langue par défaut, mais on peut servir plusieurs documents OpenAPI traduits :
const buildDoc = (lang: string) =>
new DocumentBuilder()
.setTitle(i18n.translate('swagger.title', { lang }))
.setDescription(i18n.translate('swagger.description', { lang }))
.build();
['en', 'fr'].forEach((lang) => {
const document = SwaggerModule.createDocument(app, buildDoc(lang));
SwaggerModule.setup(`docs/${lang}`, app, document);
});🔄 Versions — Nest 7 → 11 + libs tierces
- Nest 7 :
nestjs-i18nv8 utilisaitNestContextOptions; pas deI18nContext.current(), on devait injecter manuellement le service. Pas de génération de types. - Nest 8-9 :
nestjs-i18nv9-10 introduit l'AsyncLocalStorage(I18nContext.current()), supprime le besoin de passerreqexplicitement. Supportclass-validatornatif viai18nValidationMessage. - Nest 10 :
nestjs-i18nv10.4 ajoutetypesOutputPathet le support duFastify. Breaking change :I18nService.translate()retourne maintenantstringstrict (avantstring | object). - Nest 11 :
nestjs-i18nv10.5+ supporteExpress 5(Nest 11 utilisepath-to-regexpv8). Attention : la signature des routes change, vérifier que lesresolversne dépendent pas du parsing d'URL legacy. - class-validator 0.14+ : retourne des objets
ValidationErrorenrichis ;I18nValidationPipeles sérialise correctement. - ICU MessageFormat :
nestjs-i18nutiliseintl-messageformatv10 ; passage à v10 a cassé le format::currency/EUR(skeleton). Vérifier que les locales du JS engine supportent ICU (Node >= 18 compilé avec full-icu).
⚠️ Pitfalls — 6-10 pièges
I18nContext.current()retourneundefinedhors requête HTTP. Dans un cron job ou un consumer Kafka, il faut appeleri18n.translate('key', { lang: 'fr' })directement.Accept-Languageparsé sansquality value. SansAcceptLanguageResolver, unAccept-Language: en-US,fr;q=0.9est traité commeen-USbrut, manquant le fallbacken. Toujours configurer le résolveur officiel en dernier.- Mutations de fichiers JSON en dev avec
watch: true: si Webpack/SWC compile les JSON dansdist/, lewatchne voit pas les modifications source. Configurerassetsdansnest-cli.jsonavecwatchAssets: true. - Validation pipe écrasée. Si on configure
app.useGlobalPipes(new ValidationPipe())aprèsnew I18nValidationPipe(), on perd les messages traduits. L'ordre compte ;I18nValidationPipeétend déjàValidationPipe. - Clés manquantes silencieuses. Par défaut, une clé absente retourne la clé elle-même. Activer
throwOnMissingKey: trueen dev, et en prod logger un warning vialogger. - ICU skeleton non supporté.
{amount, number, ::currency/EUR}nécessite ICU >= 64 et un Node compilé avec--with-intl=full-icu. Sur Alpine, installericu-data-fullou utiliser l'imagenode:slim. - JWT resolver bloquant. Si
verifyAsync()lance, on ne doit pas faire échouer la requête : retournerundefinedpour passer au résolveur suivant. Sinon une requête publique avec un token invalide retourne 401 au lieu de simplement utiliser le fallback. - Cache CDN et
Vary: Accept-Language. Si l'API est derrière CloudFront, ajouterVary: Accept-Languageou inclure la langue dans la cache key, sinon un client enfrreçoit une réponse cachée enen. - Interpolation et XSS. Pour les emails HTML, les variables ne sont pas échappées par défaut ; utiliser
i18n.t('key', { args, escape: true })ou un moteur de templating qui échappe (Handlebars). - Mémoire et hot reload. En dev, recharger 50 fichiers JSON à chaque sauvegarde fuit de la mémoire. Limiter
watchaux namespaces actifs ou redémarrer le process. i18n.t()est SYNCHRONE. Dans les versions actuelles denestjs-i18n,I18nContext.t()etI18nService.t()/translate()retournent unstringsynchrone, pas unePromise. Leawait this.i18n.t(...)que l'on voit dans beaucoup de blogs est inoffensif (awaitsur une valeur non-thenable la renvoie telle quelle) mais trompeur : il laisse croire à un I/O async et casse le typage si vous chaînez.then(). Écrivezconst msg = i18n.t('key')sansawait. Le seul cas async est unloadercustom (Redis/S3) au démarrage, jamais à l'appelt().I18nValidationPipedoit être le SEUL pipe de validation global. Il étendValidationPipe. Si vous en mettez deux, les contraintes sont évaluées deux fois (double coût CPU) et seul le second filtre sérialise correctement. Un seul pipe, un seul filtre.- Pollution du contexte ALS sous
Promise.allmal isolé.I18nContext.current()lit l'AsyncLocalStoragedu tick courant. Si vous lancez unsetImmediate/process.nextTickou un job détaché qui survit à la requête, le contexte peut être perdu ou — pire — pointer sur une autre requête concurrente. Pour tout travail hors-requête (BullMQ, cron), passezlangexplicitement, ne lisez jamaisI18nContext.current().
🧪 Testing — exemples concrets
Test d'un service utilisant I18nService
// src/cart/cart.service.spec.ts
import { Test } from '@nestjs/testing';
import { I18nService } from 'nestjs-i18n';
import { CartService } from './cart.service';
describe('CartService', () => {
let service: CartService;
let i18n: jest.Mocked<I18nService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
CartService,
{
provide: I18nService,
useValue: {
translate: jest.fn((key: string, opts: any) => {
if (key === 'common.items' && opts.args.count === 0) return 'aucun élément';
if (key === 'common.items' && opts.args.count === 1) return 'un élément';
return `${opts.args.count} éléments`;
}),
} as any,
},
],
}).compile();
service = module.get(CartService);
i18n = module.get(I18nService);
});
it('formats plural correctly', () => {
expect(service.describeItems(0, 'fr')).toBe('aucun élément');
expect(service.describeItems(1, 'fr')).toBe('un élément');
expect(service.describeItems(5, 'fr')).toBe('5 éléments');
expect(i18n.translate).toHaveBeenCalledWith('common.items', {
lang: 'fr',
args: { count: 5 },
});
});
});Test e2e du resolver chain
// test/i18n.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('i18n (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = module.createNestApplication();
await app.init();
});
it('uses Accept-Language header', async () => {
const res = await request(app.getHttpServer())
.get('/cart/abc/summary')
.set('Accept-Language', 'fr-FR,fr;q=0.9,en;q=0.5');
expect(res.body.title).toContain('Bienvenue');
});
it('query param overrides header', async () => {
const res = await request(app.getHttpServer())
.get('/cart/abc/summary?lang=en')
.set('Accept-Language', 'fr-FR');
expect(res.body.title).toContain('Welcome');
});
it('falls back to default when locale unknown', async () => {
const res = await request(app.getHttpServer())
.get('/cart/abc/summary?lang=xx');
expect(res.body.title).toContain('Welcome');
});
afterAll(async () => app.close());
});Script de check des clés manquantes (CI)
Ce script compare toutes les clés du fichier de référence (en) avec chaque autre locale, et échoue le build si une clé manque ou si une variable d'interpolation diverge.
// scripts/i18n-check.ts
import * as fs from 'node:fs';
import * as path from 'node:path';
type Json = Record<string, unknown>;
const root = path.join(__dirname, '../i18n');
const langs = fs.readdirSync(root).filter((d) => fs.statSync(path.join(root, d)).isDirectory());
const reference = 'en';
function flatten(obj: Json, prefix = ''): Record<string, string> {
return Object.entries(obj).reduce((acc, [k, v]) => {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(acc, flatten(v as Json, key));
else acc[key] = String(v);
return acc;
}, {} as Record<string, string>);
}
function readNamespace(lang: string, ns: string): Json {
return JSON.parse(fs.readFileSync(path.join(root, lang, ns), 'utf-8'));
}
const errors: string[] = [];
const namespaces = fs.readdirSync(path.join(root, reference));
const refMaps = Object.fromEntries(namespaces.map((ns) => [ns, flatten(readNamespace(reference, ns))]));
for (const lang of langs.filter((l) => l !== reference)) {
for (const ns of namespaces) {
const target = flatten(readNamespace(lang, ns));
for (const key of Object.keys(refMaps[ns])) {
if (!(key in target)) errors.push(`[${lang}/${ns}] missing key: ${key}`);
else {
const refVars = [...refMaps[ns][key].matchAll(/\{(\w+)/g)].map((m) => m[1]);
const tgtVars = [...target[key].matchAll(/\{(\w+)/g)].map((m) => m[1]);
const missing = refVars.filter((v) => !tgtVars.includes(v));
if (missing.length) errors.push(`[${lang}/${ns}] ${key}: missing vars ${missing.join(',')}`);
}
}
for (const key of Object.keys(target)) {
if (!(key in refMaps[ns])) errors.push(`[${lang}/${ns}] orphan key (not in ${reference}): ${key}`);
}
}
}
if (errors.length) {
console.error(errors.join('\n'));
process.exit(1);
}
console.log('i18n check passed');# .github/workflows/i18n.yml
name: i18n
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx ts-node scripts/i18n-check.tsSynchronisation avec le frontend
Pour partager les locales avec une SPA React (i18next, react-intl) ou un mobile (FormatJS), le plus simple est d'exposer /i18n/:lang.json depuis l'API (un controller qui sert le JSON) ou de publier un package npm privé @my-org/i18n consommé des deux côtés. Le script CI vérifie alors que toutes les clés du frontend existent côté backend (les deux sources doivent être superposables).
@Controller('i18n')
export class I18nAssetsController {
constructor(private readonly i18n: I18nService) {}
@Get(':lang.json')
async serve(@Param('lang') lang: string) {
return this.i18n.getTranslations()[lang] ?? {};
}
}🎬 Cas d'usage concrets
E-commerce mode multi-pays — résolution locale par sous-domaine
Qui : marque DNVB française vendant en FR, BE, DE, IT, ES via des sous-domaines (fr.brand.com, de.brand.com). 12 langues à terme, formatage des prix par devise.
Problème : la résolution actuelle par header Accept-Language produit des incohérences : un utilisateur français qui partage un lien de.brand.com voit le site en français. Il faut prioriser l'URL sur le header.
@Injectable()
export class SubdomainResolver implements I18nResolver {
async resolve(context: ExecutionContext): Promise<string | undefined> {
const req = context.switchToHttp().getRequest<Request>();
const host = req.headers.host ?? '';
const subdomain = host.split('.')[0];
const localeBySubdomain: Record<string, string> = {
fr: 'fr-FR', be: 'fr-BE', de: 'de-DE', it: 'it-IT', es: 'es-ES',
};
return localeBySubdomain[subdomain];
}
}
I18nModule.forRoot({
fallbackLanguage: 'fr-FR',
loaderOptions: { path: 'i18n/', watch: true },
resolvers: [SubdomainResolver, AcceptLanguageResolver, QueryResolver],
})Gains : 100% des liens partagés conservent la locale d'origine. Le taux de conversion sur les campagnes DE a augmenté de 18% grâce à un affichage cohérent. Le fallback Accept-Language reste actif pour les sous-domaines non mappés.
SaaS RH FR/EN/ES — emails transactionnels traduits par destinataire
Qui : éditeur SaaS de gestion des congés, 400 entreprises clientes en France, Espagne, Grande-Bretagne. Notifications de validation/refus de congés envoyées par mail.
Problème : la langue d'envoi dépend du destinataire (manager ou employé), pas de l'API caller. Un manager français qui refuse un congé à un employé espagnol doit recevoir l'accusé en français, l'employé son refus en espagnol.
@Injectable()
export class LeaveNotificationService {
constructor(
private readonly mailer: MailerService,
private readonly i18n: I18nService,
private readonly users: UsersService,
) {}
async notifyLeaveDecision(leaveId: string, decision: 'APPROVED' | 'REJECTED') {
const leave = await this.repo.findOneWithUsers(leaveId);
await Promise.all([
this.sendInLanguage(leave.manager, 'leave.manager-confirmation', { leave, decision }),
this.sendInLanguage(leave.employee, `leave.employee-${decision.toLowerCase()}`, { leave, decision }),
]);
}
private async sendInLanguage(user: User, key: string, ctx: any) {
const lang = user.preferredLanguage ?? 'fr-FR';
await this.mailer.sendMail({
to: user.email,
// i18n.t() est SYNCHRONE (cf. pitfall 11) — pas de `await`
subject: this.i18n.t(`${key}.subject`, { lang, args: ctx }),
html: this.i18n.t(`${key}.body`, { lang, args: ctx }),
});
}
}Gains : 96% de satisfaction sur les notifications (sondage interne) après bascule vers la résolution par préférence utilisateur. Aucune confusion linguistique pour les équipes hispanophones.
Banque retail FR + EN — messages d'erreur de validation traduits
Qui : banque néerlandaise opérant aussi en France via une marque dédiée. L'API d'ouverture de compte accepte FR et EN, les messages d'erreur de validation doivent être traduits pour l'UX et pour la conformité ACPR (information du client).
Problème : class-validator retournait des erreurs en anglais ("must be a valid email"), incompréhensibles pour un client français. Il faut traduire systématiquement les messages de contraintes.
export class OpenAccountDto {
@IsEmail({}, { message: i18nValidationMessage('validation.IsEmail') })
email: string;
@MinLength(8, { message: i18nValidationMessage('validation.MinLength.password') })
@Matches(/[A-Z]/, { message: i18nValidationMessage('validation.password.uppercase') })
password: string;
@IsIBAN({ message: i18nValidationMessage('validation.IsIBAN') })
iban: string;
}
// main.ts
app.useGlobalPipes(new I18nValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new I18nValidationExceptionFilter({ detailedErrors: false }));Gains : zéro plainte client sur "votre site est en anglais" depuis la mise en prod. Conformité ACPR satisfaite : tout message d'erreur visible client est dans sa langue choisie. L'équipe support gagne 4h/semaine en moins de tickets de traduction.
🛠️ Exemple end-to-end
Contexte : marketplace internationale de billetterie événementielle. L'API expose les événements en FR, EN, DE, ES. Validation traduite, formatage prix selon devise locale, emails de confirmation dans la langue de l'acheteur, fallback applicatif si une traduction manque pour le titre événement.
// src/i18n/i18n.module.ts
import { Module } from '@nestjs/common';
import {
I18nModule,
AcceptLanguageResolver,
QueryResolver,
HeaderResolver,
CookieResolver,
} from 'nestjs-i18n';
import * as path from 'node:path';
@Module({
imports: [
I18nModule.forRoot({
fallbackLanguage: 'en',
loaderOptions: {
path: path.join(__dirname, '/locales/'),
watch: process.env.NODE_ENV !== 'production',
},
resolvers: [
{ use: QueryResolver, options: ['lang'] },
new HeaderResolver(['x-lang']),
new CookieResolver(['lang']),
AcceptLanguageResolver,
],
typesOutputPath: path.join(__dirname, '../../src/generated/i18n.generated.ts'),
}),
],
})
export class AppI18nModule {}
// src/booking/dto/create-booking.dto.ts
import { IsEmail, IsInt, Min, IsString, Length } from 'class-validator';
import { i18nValidationMessage } from 'nestjs-i18n';
export class CreateBookingDto {
@IsEmail({}, { message: i18nValidationMessage('validation.email.invalid') })
email: string;
@IsString()
@Length(2, 100, { message: i18nValidationMessage('validation.length') })
firstName: string;
@IsString()
@Length(2, 100, { message: i18nValidationMessage('validation.length') })
lastName: string;
@IsInt()
@Min(1, { message: i18nValidationMessage('validation.min') })
ticketCount: number;
}
// src/booking/booking.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { I18n, I18nContext } from 'nestjs-i18n';
@Controller('bookings')
export class BookingController {
constructor(
private readonly service: BookingService,
private readonly events: EventRepository,
private readonly currency: CurrencyService,
) {}
@Post(':eventId')
async book(
@Param('eventId') eventId: string,
@Body() dto: CreateBookingDto,
@I18n() i18n: I18nContext,
) {
const event = await this.events.findOneOrFail(eventId);
const lang = i18n.lang;
const title = event.translations[lang]?.title
?? event.translations[event.defaultLang]?.title
?? event.title;
const booking = await this.service.create({
...dto, eventId, language: lang,
});
const total = await this.currency.format(booking.totalCents, event.currency, lang);
return {
booking,
event: { id: event.id, title },
total,
// i18n.t() est synchrone — pas de `await` (cf. pitfall 11)
message: i18n.t('booking.created', { args: { firstName: dto.firstName, total, title } }),
};
}
}
// src/booking/booking-mailer.service.ts
import { Injectable } from '@nestjs/common';
import { I18nService } from 'nestjs-i18n';
@Injectable()
export class BookingMailerService {
constructor(
private readonly mailer: MailerService,
private readonly i18n: I18nService,
private readonly currency: CurrencyService,
) {}
async sendConfirmation(booking: BookingWithEvent) {
const lang = booking.language;
const total = await this.currency.format(booking.totalCents, booking.event.currency, lang);
const eventTitle = booking.event.translations[lang]?.title ?? booking.event.title;
// i18n.t() est synchrone — pas de `await` (cf. pitfall 11)
const subject = this.i18n.t('booking.email.subject', { lang, args: { eventTitle } });
const html = this.i18n.t('booking.email.body', {
lang,
args: {
firstName: booking.firstName,
eventTitle,
eventDate: this.formatDate(booking.event.startsAt, lang),
total,
ticketCount: booking.ticketCount,
},
});
await this.mailer.sendMail({
to: booking.email,
subject,
html,
attachments: [{
filename: `ticket-${booking.id}.pdf`,
content: await this.pdf.render(booking, lang),
}],
});
}
private formatDate(date: Date, lang: string): string {
return new Intl.DateTimeFormat(lang, {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit',
}).format(date);
}
}
// src/i18n/currency.service.ts
@Injectable()
export class CurrencyService {
async format(cents: number, currency: string, lang: string): Promise<string> {
return new Intl.NumberFormat(lang, {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(cents / 100);
}
}
// main.ts
import { I18nValidationPipe, I18nValidationExceptionFilter } from 'nestjs-i18n';
app.useGlobalPipes(new I18nValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new I18nValidationExceptionFilter({ detailedErrors: false }));Résolution multi-resolvers (query > header > cookie > Accept-Language), fallback applicatif pour les titres événements quand une traduction manque, validation class-validator traduite par messages-keys, formatage devise/date via Intl natif (zéro lib supplémentaire), email de confirmation dans la langue de l'acheteur avec PDF de billet localisé. La plateforme sert 50 k réservations/mois en 4 langues avec une cohérence linguistique totale du clic au mail.
🤖 i18n + agents IA — servir des LLM multilingues depuis NestJS
C'est ici que nestjs-i18n rencontre votre stack IA. Trois problèmes distincts apparaissent quand une API NestJS sert un agent Claude à des utilisateurs multilingues, et il ne faut pas les confondre :
| Couche | Quoi traduire | Outil |
|---|---|---|
| Erreurs/UI système (rate-limit dépassé, quota épuisé, timeout) | clés statiques, déterministes | nestjs-i18n (ce dont parle ce fichier) |
| Réponse du modèle (le texte généré) | langue de génération | instruction système au LLM, pas nestjs-i18n |
| Contenu structuré renvoyé par un outil (labels, statuts d'un tool-call) | clés statiques injectées dans la réponse | nestjs-i18n côté serveur, avant de renvoyer au modèle |
Mental model staff :
nestjs-i18nne traduit JAMAIS la sortie du modèle. Vous demandez au modèle de répondre dans la locale résolue (i18n.lang) via le system prompt, et vous traduisez vous-même tout ce qui est déterministe (messages d'erreur, libellés d'outils, garde-fous de coût). Mélanger les deux donne des bugs subtils : un modèle qui répond en anglais parce que vous avez traduit son prompt système mais oublié l'instruction de langue.
1. Propager la locale résolue dans le system prompt
La locale est déjà dans l'I18nContext. On l'injecte dans l'appel Claude — modèle phare claude-opus-4-8, ou claude-sonnet-4-6 pour un meilleur rapport latence/coût en streaming de chat.
// src/agent/agent.controller.ts
import { Controller, Post, Body, Res, Req } from '@nestjs/common';
import { I18n, I18nContext } from 'nestjs-i18n';
import type { Response, Request } from 'express';
import { AgentService } from './agent.service';
@Controller('agent')
export class AgentController {
constructor(private readonly agent: AgentService) {}
@Post('stream')
async stream(
@Body() body: { prompt: string },
@I18n() i18n: I18nContext,
@Req() req: Request,
@Res() res: Response,
) {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
// CRITIQUE pour un cache CDN : la réponse dépend de la langue
Vary: 'Accept-Language',
});
// AbortController câblé sur la déconnexion client → on annule l'appel LLM
const ac = new AbortController();
req.on('close', () => ac.abort());
try {
for await (const token of this.agent.streamReply(
body.prompt,
i18n.lang, // la locale résolue par le resolver chain
ac.signal,
)) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
res.write('event: done\ndata: {}\n\n');
} catch (err) {
// Erreur SYSTÈME traduite par nestjs-i18n, pas par le modèle
const message = i18n.t('agent.errors.streamFailed');
res.write(`event: error\ndata: ${JSON.stringify({ message })}\n\n`);
} finally {
res.end();
}
}
}2. Le client LLM injecté par DI (jamais new Anthropic() dans un champ)
Instancier le SDK dans un champ de service rend le testing impossible, fuit la clé API dans le code, et empêche de configurer retries/timeout par environnement. On le fournit via forRootAsync.
// src/llm/llm.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC');
@Global()
@Module({
providers: [
{
provide: ANTHROPIC,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Anthropic({
apiKey: config.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 4, // le SDK gère le backoff 429/5xx, ne le réimplémentez pas
timeout: 60_000,
}),
},
],
exports: [ANTHROPIC],
})
export class LlmModule {}// src/agent/agent.service.ts
import { Inject, Injectable } from '@nestjs/common';
import type Anthropic from '@anthropic-ai/sdk';
import { I18nService } from 'nestjs-i18n';
import { ANTHROPIC } from '../llm/llm.module';
@Injectable()
export class AgentService {
constructor(
@Inject(ANTHROPIC) private readonly llm: Anthropic,
private readonly i18n: I18nService,
) {}
async *streamReply(
prompt: string,
lang: string,
signal: AbortSignal,
): AsyncGenerator<string> {
// On DEMANDE au modèle de répondre dans la locale. nestjs-i18n
// fournit juste le libellé humain de la langue, pas la traduction.
const languageName = this.i18n.t('languages.self', { lang });
const stream = this.llm.messages.stream(
{
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: `Reply strictly in ${languageName} (${lang}). Keep the user's locale conventions for dates, numbers and currency.`,
messages: [{ role: 'user', content: prompt }],
},
{ signal }, // l'AbortSignal du contrôleur coupe le réseau côté SDK
);
for await (const event of stream) {
if (
event.type === 'content_block_delta' &&
event.delta.type === 'text_delta'
) {
yield event.delta.text;
}
}
}
}3. Boucle agentique (tool-use) : libellés d'outils localisés, raisonnement non touché
Quand le modèle appelle un outil (ex. get_order_status), votre serveur exécute la fonction et renvoie un résultat. Les données restent brutes ; mais si vous renvoyez aussi un libellé destiné à l'humain (statut affiché dans une timeline UI), il passe par nestjs-i18n :
// résultat d'un tool-call renvoyé au modèle ET à l'UI
async function handleToolUse(name: string, input: any, i18n: I18nService, lang: string) {
if (name === 'get_order_status') {
const order = await this.orders.find(input.orderId);
return {
// données brutes pour le raisonnement du modèle
status: order.status,
eta: order.eta.toISOString(),
// libellé humain localisé pour la timeline UI (clé statique → nestjs-i18n)
label: i18n.t(`orders.status.${order.status}`, { lang }),
};
}
}4. Jobs IA asynchrones (BullMQ) : la locale fait partie de la clé d'idempotence
Un agent qui génère un rapport en arrière-plan ne vit pas dans une requête HTTP → I18nContext.current() y retourne undefined (pitfall n°1). Il faut sérialiser lang dans le payload du job, et l'inclure dans la clé d'idempotence (un même prompt en fr et en en sont deux générations distinctes, deux coûts) :
await this.queue.add(
'generate-report',
{ userId, prompt, lang: i18n.lang }, // lang sérialisée, lue plus tard sans ALS
{
// idempotence : même user + même prompt + même langue = même job
jobId: `report:${userId}:${hash(prompt)}:${i18n.lang}`,
attempts: 3,
backoff: { type: 'exponential', delay: 2_000 },
},
);Dans le worker, on traduit les erreurs système avec lang explicite, et on persiste la sortie partielle si l'appel est annulé à mi-stream (un token coûte de l'argent : ne le jetez pas).
// worker
async process(job: Job<{ prompt: string; lang: string }>) {
const { prompt, lang } = job.data;
try {
return await this.agent.generate(prompt, lang);
} catch (err) {
// surtout PAS I18nContext.current() ici : hors requête HTTP
const message = this.i18n.t('agent.errors.jobFailed', { lang });
this.logger.error({ jobId: job.id, lang, message });
throw err; // BullMQ rejoue selon la policy de backoff
}
}Garde-fous edge (idempotency / rate-limit / cost-guard) : ils sont language-agnostic en logique mais language-aware en message. La décision « bloquer » se prend sur l'IP/tenant ; le message « Vous avez atteint votre quota » se traduit via nestjs-i18n avec la locale résolue. Ne traduisez jamais la décision, seulement sa restitution.
🏗️ Production : observabilité, perf, sécurité
Observabilité — instrumentez les clés manquantes en prod (ne vous contentez pas de throwOnMissingKey qui est réservé au dev). Passez un logger custom à I18nModule.forRoot et incrémentez un compteur Prometheus i18n_missing_key_total{lang,key}. Une dérive de ce compteur signale qu'un déploiement a introduit une clé non traduite — c'est votre détecteur de régression i18n en prod.
Perf — l'ICU MessageFormat compile le pattern à chaque appel par défaut. Sur un endpoint chaud (10 k req/s), c'est mesurable. intl-messageformat met en cache les formatters compilés par (message, locale) ; nestjs-i18n exploite ce cache, mais il s'invalide à chaque watch reload — d'où l'interdiction absolue de watch: true en prod (ligne watch: process.env.NODE_ENV !== 'production', déjà correcte dans les exemples). Pré-chargez toutes les locales au boot (I18nJsonLoader synchrone) plutôt qu'un loader réseau lazy si la latence p99 compte.
Sécurité — trois vecteurs :
- ICU injection : ne JAMAIS construire un message ICU à partir d'input utilisateur (
i18n.t(userInput)). La clé doit être une constante du code ; les args sont des données, la clé ne l'est jamais. Uni18n.t(req.query.key)permet d'exfiltrer n'importe quelle clé de traduction et, avec un pattern forgé, de provoquer un DoS de parsing. - XSS via interpolation HTML : pitfall n°9 — les emails/templates HTML doivent échapper les args (
escape: trueou moteur échappant). - Cache poisoning : sans
Vary: Accept-Language(ou langue dans la cache key), un attaquant peut empoisonner le cache CDN avec une réponse dans une langue, servie ensuite à tous.
🏋️ Exercices
Faites-les dans l'ordre. Chacun monte d'un cran : implémenter → durcir pour la prod → casser puis réparer.
Exo 1 — Resolver tenant-aware (implémenter)
Objectif : écrire un TenantI18nResolver qui résout la locale depuis req.tenant.defaultLocale (chargé par un middleware), prioritaire sur Accept-Language mais surclassé par ?lang=. Indice/Solution : implémentez I18nResolver.resolve(), lisez context.switchToHttp().getRequest().tenant?.defaultLocale, placez le resolver entre QueryResolver (avant) et AcceptLanguageResolver (après) dans le tableau resolvers. L'ordre du tableau EST la priorité.
Exo 2 — Type-safety totale + CI bloquante (production-grade)
Objectif : activer typesOutputPath, faire échouer le build CI si (a) une clé existe dans en mais pas dans fr, ou (b) une variable d'interpolation {x} diverge entre locales. Indice/Solution : réutilisez le script scripts/i18n-check.ts du fichier, branchez-le en pre-push Husky et en GitHub Action. Ajoutez un test qui importe I18nTranslations et vérifie expectType<...> sur une clé connue pour garantir que la génération de types a tourné.
Exo 3 — Loader Redis avec cache LRU et invalidation (production-grade)
Objectif : remplacer I18nJsonLoader par un loader qui lit les traductions depuis Redis, avec un LRU borné (1000 entrées) et une invalidation par pub/sub quand un traducteur pousse une mise à jour — sans redémarrer l'app. Indice/Solution : étendez I18nLoader, gardez un Map borné, abonnez-vous à un canal Redis i18n:invalidate qui cache.delete(lang). Attention au thundering herd : sur miss, un seul fetch par lang (verrou en mémoire / p-memoize).
Exo 4 — Streaming LLM multilingue avec annulation bout-en-bout (stack IA)
Objectif : exposer POST /agent/stream qui streame des tokens Claude en SSE dans la locale résolue, avec un bouton Stop côté client qui annule l'appel LLM côté serveur (pas seulement la connexion). Indice/Solution : AbortController sur req.on('close'), passez signal à llm.messages.stream(..., { signal }). Vérifiez avec curl -N puis Ctrl-C que vous voyez l'abort côté serveur (log) et que la facturation Anthropic s'arrête (pas de tokens facturés après l'abort). Header Vary: Accept-Language.
Exo 5 — Casser puis réparer : la fuite de contexte ALS (break-then-fix)
Objectif : reproduire un bug où une réponse fr reçoit un message traduit en en sous charge concurrente, puis le corriger. Indice/Solution : créez un service qui fait setImmediate(() => log(I18nContext.current()?.lang)) — sous 100 requêtes concurrentes mêlant fr/en, vous verrez le contexte fuir/disparaître. Fix : ne lisez jamais I18nContext.current() dans un callback détaché ; capturez i18n.lang dans une const au début du handler et passez-la explicitement. Mesurez le taux d'erreur avant/après avec autocannon.
Exo 6 — Job BullMQ idempotent et cost-aware (stack IA, hard)
Objectif : un endpoint qui enfile une génération de rapport IA ; deux POST identiques (même user, prompt, langue) ne doivent produire qu'un appel LLM facturé, et un retry après crash ne doit pas re-facturer un rapport déjà partiellement généré. Indice/Solution : jobId déterministe = report:${userId}:${hash(prompt)}:${lang} (BullMQ déduplique sur jobId). Persistez les tokens reçus dans Redis au fil du stream ; au retry, reprenez du dernier offset ou court-circuitez si status === 'done'. La langue fait partie de la clé : fr et en sont deux générations distinctes.
🎤 En entretien
Q : Où traduisez-vous la sortie d'un LLM dans une API NestJS multilingue ? R : Nulle part avec nestjs-i18n — on instruit le modèle de répondre dans la locale résolue (i18n.lang) via le system prompt. nestjs-i18n ne traduit que le déterministe : erreurs système, libellés d'outils, garde-fous de quota. Confondre les deux est l'erreur classique.
Q : I18nContext.current() retourne undefined dans un worker BullMQ — pourquoi, et comment vous y prenez-vous ? R : Le contexte vit dans un AsyncLocalStorage rempli par le resolver chain à l'entrée HTTP ; un job hors-requête n'a pas ce store. On sérialise lang dans le payload du job et on appelle i18n.translate('key', { lang }) explicitement. Même raisonnement pour cron, Kafka, ou tout callback détaché (setImmediate).
Q : Comment garantissez-vous qu'aucune clé manquante n'atteint la prod ? R : Trois lignes de défense : (1) typesOutputPath → erreur de compilation si une clé référencée n'existe pas ; (2) script CI qui diffe chaque locale contre la référence en et compare aussi les variables d'interpolation, bloquant le PR ; (3) en prod, un logger custom qui incrémente i18n_missing_key_total{lang,key} en Prometheus pour détecter les régressions runtime. throwOnMissingKey reste cantonné au dev.
Q : Pourquoi Vary: Accept-Language est-il critique, et que se passe-t-il sans lui derrière un CDN ? R : La même URL produit des réponses différentes selon la langue résolue. Sans Vary (ou sans la langue dans la cache key), le CDN sert la première réponse cachée à tous : un utilisateur fr reçoit du contenu en, ou pire, un attaquant empoisonne le cache. La cache key DOIT inclure la dimension linguistique — c'est le même principe que pour le Authorization ou l'Accept-Encoding.
🔁 Quand utiliser / éviter
Utiliser quand : produit grand public multilingue, application B2B avec tenants dans plusieurs pays, emails transactionnels traduits, messages d'erreur visibles par l'utilisateur final, validation traduite, factures et reçus formatés selon la locale.
Éviter quand : API purement interne (entre microservices), application mono-langue avec aucun horizon international, contenu éditorial volumineux (préférer un CMS headless type Strapi/Contentful), traductions de UI uniquement (laisser au frontend). Pour les contenus dynamiques (descriptions de produits), préférer une table translations en base avec une stratégie de fallback applicative.
🔗 Liens
- Documentation officielle
nestjs-i18n: https://nestjs-i18n.com - ICU MessageFormat : https://unicode-org.github.io/icu/userguide/format_parse/messages/
- RFC 7231 (Accept-Language) : https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.5
- BCP 47 (Language tags) : https://www.rfc-editor.org/info/bcp47
intl-messageformat(FormatJS) : https://formatjs.io- Repo GitHub : https://github.com/toonvanstrijp/nestjs-i18n
- Article « ICU pluralization deep dive » : https://phrase.com/blog/posts/i18n-icu-messageformat/