Aller au contenu

À distanceHeures locales: Kigali, Nairobi, Dubaï

5 min de lectureReact

Internationaliser une application Next.js avec i18next

Namespaces, clés typées, détection de la langue et traductions chargées à la demande : les décisions qui gardent une configuration i18next maintenable dans une application Next.js à mesure que les écrans et les langues s'accumulent.

Ajouter une deuxième langue à une application web ressemble à un travail de rechercher-remplacer. On enveloppe les chaînes, on livre un fichier JSON par langue, terminé. En réalité, le travail porte sur la structure : où vit chaque chaîne, qui remarque qu'une clé manque, comment le serveur sait dans quelle langue rendre la page, et quelle part de chaque langue le navigateur doit télécharger.

Ma première pull request sur Ever Teams, #1643, date du 25 octobre 2023 et a ajouté l'internationalisation à l'application web. Son titre cite trois bibliothèques : ni18n, i18next et react-i18next. Cet article ne reconstitue pas le contenu de cette pull request. Il passe en revue les décisions que toute configuration i18next doit prendre dans une application Next.js, avec du code écrit pour l'article, pour l'App Router et les versions actuelles d'i18next et de react-i18next.

Ce qui est vérifié et ce qui est général

Le numéro, le titre, l'auteur et les dates de la pull request viennent de l'historique public de github.com/ever-co/ever-teams, consulté le 2026-10-04 : #1643 a été ouverte et fusionnée le 25 octobre 2023 et a modifié 152 fichiers. Tout le reste est de la technique générale. Les exemples ne sont pas tirés d'Ever Teams, et sa configuration de 2023 n'est pas décrite ici.

Namespaces : où vit chaque chaîne

Un gros translation.json par langue suffit pour une page d'accueil et cesse de suffire vers la troisième fonctionnalité. Deux contributeurs qui modifient le même fichier entrent en conflit à chaque fusion, et chaque page télécharge toutes les chaînes. Les namespaces découpent les traductions par zone : un namespace common pour les mots présents partout, comme Enregistrer, Annuler ou la navigation, et un namespace par fonctionnalité.

textIllustration
locales/
  en/
    common.json
    tasks.json
  fr/
    common.json
    tasks.json

Une règle qui tient : une clé appartient au namespace de l'écran qui l'affiche, et ne passe dans common que lorsqu'un deuxième écran a besoin des mêmes mots avec le même sens. Construire des phrases à partir de fragments est l'autre piège. « Assigned to » suivi d'un nom fonctionne en anglais et casse dans les langues qui placent le nom ailleurs ou accordent le verbe avec lui. Gardez des phrases entières et utilisez l'interpolation.

Voici locales/fr/tasks.json :

jsonIllustration
{
  "title": "Tâches",
  "count_one": "{{count}} tâche",
  "count_other": "{{count}} tâches",
  "assignedTo": "Assignée à {{name}}"
}

Les pluriels utilisent des suffixes résolus par Intl.PluralRules, donc chaque langue a ses propres règles sans une ligne de code. Le français et l'anglais divergent déjà sur zéro : l'anglais dit « 0 tasks », le français dit « 0 tâche », parce que le français traite zéro comme un singulier.

Des clés typées

i18next peut indiquer à TypeScript la forme des ressources. Déclarez les namespaces une fois, à partir des fichiers anglais, et chaque appel à t est vérifié à la compilation.

tsIllustration
// i18next.d.ts
import 'i18next';
import type common from './locales/en/common.json';
import type tasks from './locales/en/tasks.json';
 
declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common';
    resources: {
      common: typeof common;
      tasks: typeof tasks;
    };
  }
}
tsxIllustration
'use client';
import { useTranslation } from 'react-i18next';
 
export function TaskCount({ count }: { count: number }) {
  const { t } = useTranslation('tasks');
  // t('cuont') ne compilerait pas
  return <span>{t('count', { count })}</span>;
}

Les clés de pluriel sont typées sous leur nom de base, donc count est valide alors que le fichier ne contient que count_one et count_other. Les types suivent les fichiers anglais, et rien d'autre. Un fichier français auquel il manque une clé compile quand même, et l'utilisateur voit le texte anglais de repli. Un court script en CI qui compare l'ensemble des clés de chaque langue à celui de l'anglais comble ce trou, et il attrape plus de choses qu'aucune revue.

Détecter la langue

Un ordre raisonnable : la langue de l'URL l'emporte, puis un cookie posé quand l'utilisateur choisit une langue, puis l'en-tête Accept-Language du navigateur, puis une valeur par défaut. Mettre la langue dans le chemin, comme dans /fr/tasks, rend chaque version indexable et partageable, et le serveur rend la bonne langue sans deviner. Dans Next.js 16, le point d'entrée qui s'exécute avant la fin d'une requête s'appelle Proxy. Les versions précédentes l'appellent Middleware : le fichier est middleware.ts, la fonction exportée s'appelle middleware, et le reste du code est le même.

tsIllustration
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server';
 
const locales = ['en', 'fr'];
const fallback = 'en';
 
function pickLocale(request: NextRequest): string {
  const saved = request.cookies.get('lang')?.value;
  if (saved && locales.includes(saved)) return saved;
  const header = request.headers.get('accept-language') ?? '';
  const preferred = header
    .split(',')
    .map((part) => part.split(';')[0]?.trim().slice(0, 2).toLowerCase());
  return preferred.find((code) => code !== undefined && locales.includes(code)) ?? fallback;
}
 
export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  if (locales.some((locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`))) {
    return NextResponse.next();
  }
  const url = request.nextUrl.clone();
  url.pathname = `/${pickLocale(request)}${pathname}`;
  return NextResponse.redirect(url);
}
 
export const config = {
  matcher: ['/((?!api|_next|.*\\..*).*)'],
};

L'analyse de l'en-tête ignore les poids de qualité. Les navigateurs listent les langues par ordre de préférence, donc la première langue prise en charge est un choix raisonnable. Quand ce raccourci ne suffit plus, @formatjs/intl-localematcher fait la négociation complète.

Charger les traductions à la demande

Importer tous les fichiers JSON dans le bundle client est la configuration simple, et son coût grandit avec chaque langue et chaque namespace. i18next-resources-to-backend prend une fonction à la place. Un import dynamique dans cette fonction fait de chaque couple langue et namespace un fichier à part, téléchargé la première fois qu'une page le demande.

Côté serveur, il y a une règle de plus. L'export par défaut d'i18next est un singleton avec une seule langue courante, et un serveur rend en même temps des requêtes d'utilisateurs différents. Une instance neuve par requête empêche un rendu en français de basculer un rendu en anglais en plein milieu.

tsIllustration
// i18n/server.ts
import { createInstance } from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
 
type Namespace = 'common' | 'tasks';
 
export async function getT(lng: string, ns: Namespace = 'common') {
  const i18n = createInstance();
  await i18n
    .use(
      resourcesToBackend(
        (language: string, namespace: string) => import(`../locales/${language}/${namespace}.json`),
      ),
    )
    .init({ lng, ns, defaultNS: 'common', fallbackLng: 'en' });
  return i18n.getFixedT(lng, ns);
}

Un Server Component appelle await getT(lang, 'tasks') et obtient un t lié à cette langue. Le client a besoin de la même langue et des namespaces déjà chargés par le serveur, sinon son premier rendu retombe sur l'anglais et React signale une erreur d'hydratation. Passer les ressources chargées du layout au provider client, qui les ajoute à sa propre instance avant le premier rendu, évite l'erreur et un second téléchargement des mêmes fichiers.

Ce qu'il faut mettre en place dès le premier jour

L'essentiel de la dette d'i18n, c'est du texte qui n'est jamais passé par t. Une règle de lint contre les chaînes littérales dans le JSX, comme no-literal-string d'eslint-plugin-i18next, l'arrête dès la pull request, et le contrôle des clés en CI garde les langues alignées une fois les chaînes en place.

La vérification la moins chère reste la pseudo-locale : une langue supplémentaire générée depuis l'anglais, avec des chaînes accentuées et allongées. Basculez dessus et chaque libellé codé en dur saute aux yeux, comme chaque mise en page qui casse quand un libellé s'allonge. Les libellés français sont souvent plus longs que les anglais, et un bouton dimensionné pour « Save » ne contiendra pas forcément « Enregistrer ». Mieux vaut l'apprendre avant que le premier traducteur ouvre les fichiers.