Traduire un site ne se résume pas à remplacer « Bonjour » par « Hello ». C'est une décision d'architecture qui touche le routage, les métadonnées, la performance et — celui qu'on oublie toujours — le confort de l'équipe qui maintiendra les traductions dans deux ans.
Sur l'App Router, l'internationalisation est plus structurée qu'avant, mais
elle n'est plus fournie par le framework : la clé i18n de next.config.js
appartenait au Pages Router et n'a pas d'équivalent ici. C'est à toi de poser
les pièces. Bonne nouvelle : elles sont peu nombreuses et le résultat est plus
prévisible.
La langue doit être dans l'URL
Il existe plusieurs manières de retenir la langue : un cookie, le
localStorage, l'en-tête Accept-Language. Toutes sont invisibles pour un
robot d'indexation.
Si la même URL sert du français à l'un et de l'anglais à l'autre, Google n'indexe qu'une seule version — et ce ne sera pas forcément celle que tu voulais. Pire : un utilisateur qui partage un lien n'a aucune garantie que son destinataire verra la même page. Pour le SEO comme pour le partage, la structure d'URL est la seule stratégie viable.
| Stratégie | Exemple | Quand la choisir |
|---|---|---|
| Sous-chemins | monsite.com/fr, /en |
Défaut. Un domaine, une autorité, un déploiement |
| Sous-domaines | fr.monsite.com |
Infrastructures ou équipes séparées par région |
| Domaines dédiés | monsite.fr, monsite.com |
Marques ou entités juridiques distinctes par pays |
Dans la quasi-totalité des cas, les sous-chemins gagnent : tu conserves l'autorité de domaine accumulée, et l'infrastructure reste unique.
Concrètement, cela veut dire un segment dynamique à la racine :
src/app/
└── [locale]/
├── layout.tsx
├── page.tsx
└── work/
└── [slug]/page.tsxPréfixer la langue par défaut, ou non ?
Question qui revient toujours : faut-il servir le français sur / ou sur
/fr ? Les deux se défendent, mais elles n'ont pas le même coût.
Toujours préfixer (/fr, /en) donne une symétrie parfaite : une seule
forme d'URL, un seul chemin de code, aucun cas particulier. C'est le choix par
défaut recommandé.
Ne pas préfixer la langue par défaut (/ pour le français, /en pour
l'anglais) donne des URL plus courtes et évite une redirection à l'arrivée,
mais impose de gérer partout un cas particulier : dans le middleware, dans les
liens internes, dans le sitemap, dans les balises hreflang. Ne le choisis que
si les URL sont un vrai enjeu produit — et sache que le bug reviendra six fois.
Le middleware, chef d'orchestre
Le middleware intercepte chaque requête avant le rendu. C'est lui qui décide vers quelle langue diriger un visiteur qui arrive sur une URL sans préfixe.
import { NextResponse, type NextRequest } from "next/server";
export const locales = ["fr", "en"] as const;
export const defaultLocale = "fr";
type Locale = (typeof locales)[number];
function resolveLocale(request: NextRequest): Locale {
// 1. Un choix explicite déjà fait par l'utilisateur prime sur tout.
const saved = request.cookies.get("locale")?.value;
if (saved && locales.includes(saved as Locale)) return saved as Locale;
// 2. Sinon, la préférence du navigateur.
const header = request.headers.get("accept-language") ?? "";
const preferred = header.split(",")[0]?.split("-")[0];
if (locales.includes(preferred as Locale)) return preferred as Locale;
return defaultLocale;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = locales.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
);
if (hasLocale) return NextResponse.next();
const locale = resolveLocale(request);
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(url);
}
export const config = {
// On exclut les assets : les faire transiter par le middleware coûte cher
// pour rien, et casse le cache des fichiers statiques.
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};Trois détails qui font la différence :
- Le cookie avant l'en-tête. Un utilisateur qui a explicitement choisi
l'anglais ne doit pas être renvoyé en français à chaque visite parce que son
navigateur est configuré en
fr-FR. Un choix explicite est une donnée, pas une préférence à deviner. - Le
matcher. Sans exclusion des assets, chaque image passe par le middleware : du temps de calcul pur, et une source de bugs de cache. - La redirection est temporaire.
NextResponse.redirectrenvoie un 307 par défaut, et c'est ce qu'on veut : un 301 serait mis en cache par le navigateur, et l'utilisateur resterait prisonnier de la première langue détectée.
Le sélecteur de langue
Un point souvent bâclé : changer de langue doit conserver la page courante. Renvoyer systématiquement à l'accueil est une petite trahison qui coûte cher en confiance.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { locales } from "@/middleware";
export function LocaleSwitcher({ current }: { current: string }) {
const pathname = usePathname();
// On remplace uniquement le premier segment, le reste du chemin est conservé.
const rest = pathname.split("/").slice(2).join("/");
return locales.map((locale) => (
<Link
key={locale}
href={`/${locale}${rest ? `/${rest}` : ""}`}
hrefLang={locale}
aria-current={locale === current ? "true" : undefined}
onClick={() => {
document.cookie = `locale=${locale}; path=/; max-age=31536000; samesite=lax`;
}}
>
{locale.toUpperCase()}
</Link>
));
}Des dictionnaires typés, pas des objets JSON
L'erreur classique est d'utiliser des objets JSON sans protection. Tu oublies
une clé dans en.json, personne ne le voit, et la page anglaise affiche
common.cta.submit en production.
TypeScript peut rendre cette erreur impossible. On déclare une langue de référence, et toutes les autres doivent s'y conformer.
import fr from "./fr.json";
// Le français fait foi : sa forme devient le contrat.
export type Dictionary = typeof fr;
const dictionaries = {
fr: () => import("./fr.json").then((m) => m.default),
// Le typage force `en.json` à avoir exactement les mêmes clés.
en: () => import("./en.json").then((m) => m.default as Dictionary),
} satisfies Record<string, () => Promise<Dictionary>>;
export type Locale = keyof typeof dictionaries;
export async function getDictionary(locale: Locale): Promise<Dictionary> {
return dictionaries[locale]();
}Il manque une clé dans en.json ? Le build échoue. C'est exactement ce qu'on
veut : une traduction incomplète est un bug, pas un détail cosmétique.
Les imports dynamiques ont un second effet : seul le dictionnaire de la langue demandée est envoyé au client. Sur un site en huit langues, la différence est loin d'être théorique.
import { getDictionary, type Locale } from "@/i18n/dictionaries";
export default async function Page({
params,
}: {
params: Promise<{ locale: Locale }>;
}) {
const { locale } = await params;
const t = await getDictionary(locale);
return <h1>{t.home.title}</h1>;
}Un garde-fou en intégration continue
Le typage attrape les clés manquantes, pas les clés vides ni celles restées en français dans le fichier anglais. Un script de quelques lignes couvre le reste, et tourne en une seconde.
import fr from "../src/i18n/fr.json";
import en from "../src/i18n/en.json";
type Json = { [key: string]: string | Json };
function flatten(obj: Json, prefix = ""): Record<string, string> {
return Object.entries(obj).reduce<Record<string, string>>((acc, [k, v]) => {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") acc[key] = v;
else Object.assign(acc, flatten(v, key));
return acc;
}, {});
}
const source = flatten(fr as Json);
const target = flatten(en as Json);
const problems: string[] = [];
for (const [key, value] of Object.entries(source)) {
if (!(key in target)) problems.push(`manquante : ${key}`);
else if (!target[key].trim()) problems.push(`vide : ${key}`);
else if (target[key] === value && value.length > 12)
problems.push(`non traduite : ${key}`);
}
if (problems.length) {
console.error(problems.join("\n"));
process.exit(1);
}Le seuil de 12 caractères évite les faux positifs sur les mots identiques dans les deux langues — « Description », « Configuration », « Format ».
Pluriels et interpolation
C'est le moment où beaucoup de projets improvisent, et où les traductions
commencent à sonner faux. Un « ${n} résultat(s) » est un aveu.
Les règles de pluriel ne se ressemblent pas d'une langue à l'autre : le
français en a deux formes, l'anglais deux aussi mais avec une frontière
différente (zéro est pluriel en anglais, singulier en français), le polonais en
a quatre, l'arabe six. Intl.PluralRules connaît ces règles.
type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>>;
export function plural(locale: string, count: number, forms: PluralForms) {
const rule = new Intl.PluralRules(locale).select(count);
const template = forms[rule] ?? forms.other ?? "";
return template.replace("{count}", new Intl.NumberFormat(locale).format(count));
}
plural("fr", 0, { one: "{count} résultat", other: "{count} résultats" });
// "0 résultat" — en français, zéro est singulier
plural("en", 0, { one: "{count} result", other: "{count} results" });
// "0 results" — en anglais, zéro est plurielSi ton catalogue dépasse quelques dizaines de clés à pluriel, passe au format
ICU ({count, plural, one {# résultat} other {# résultats}}) que next-intl et
FormatJS savent lire nativement. Tu écriras moins de code, et surtout tes
traducteurs reconnaîtront un format standard.
Les traductions qui contiennent du balisage
Second piège : « Lis nos conditions générales avant de continuer », où seule une partie est un lien. La tentation est de couper la phrase en trois clés — c'est intraduisible, parce que l'ordre des mots change d'une langue à l'autre.
La bonne approche garde la phrase entière et laisse le code remplir les balises.
{
"legal": {
"notice": "Lis nos <link>conditions générales</link> avant de continuer."
}
}// La phrase reste une unité traduisible ; seul le rendu de <link> est en code.
<RichText
value={t.legal.notice}
tags={{ link: (chunks) => <Link href="/cgu">{chunks}</Link> }}
/>next-intl fournit ce mécanisme (t.rich) ; sinon une trentaine de lignes
suffisent à le reproduire.
Les métadonnées, grandes oubliées
Traduire l'interface est la partie visible. La partie qui décide de ton
référencement, elle, est dans le <head>.
L'attribut lang
Il doit changer avec la langue. Ce n'est pas cosmétique : les lecteurs d'écran s'en servent pour choisir leur moteur de synthèse vocale. Un texte anglais lu avec une prononciation française est littéralement inintelligible.
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}hreflang et canonique
hreflang dit aux moteurs que deux URL sont la même page dans deux langues.
Sans lui, tes versions se concurrencent au lieu de se renforcer.
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
return {
alternates: {
canonical: `https://monsite.com/${locale}`,
languages: {
fr: "https://monsite.com/fr",
en: "https://monsite.com/en",
// Vers qui envoyer une langue non couverte.
"x-default": "https://monsite.com/fr",
},
},
};
}N'oublie pas x-default : c'est lui qui gère le visiteur japonais quand tu ne
publies qu'en français et en anglais.
Deux règles souvent violées : les liens hreflang doivent être
réciproques — si /fr pointe vers /en, /en doit pointer vers /fr —
et la canonique de chaque page doit pointer vers elle-même, pas vers la
version de la langue par défaut.
Le sitemap
Un sitemap monolingue annule une partie du travail. next-sitemap fait le
travail, mais l'API native suffit largement.
import type { MetadataRoute } from "next";
import { locales } from "@/middleware";
const routes = ["", "/work", "/articles"];
const base = "https://monsite.com";
export default function sitemap(): MetadataRoute.Sitemap {
return routes.flatMap((route) =>
locales.map((locale) => ({
url: `${base}/${locale}${route}`,
lastModified: new Date(),
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${base}/${l}${route}`]),
),
},
})),
);
}Les images aussi
C'est le détail que presque personne ne traite : si ton produit a une interface, montre une capture en anglais aux anglophones. Une capture d'écran dans une langue que le visiteur ne lit pas transforme une démonstration en obstacle.
Le plus simple est de nommer les fichiers par langue —
/screens/dashboard.fr.png — et de composer le chemin à partir de la locale.
Pense à traduire aussi le texte alternatif : c'est une chaîne comme une autre,
et elle a plus d'impact que la plupart.
Dates, nombres et devises : Intl, pas une librairie
Aucune dépendance n'est nécessaire. L'API Intl est dans le navigateur et dans
Node depuis des années, et elle connaît les conventions locales bien mieux que
n'importe quel format maison.
const prix = 1234.5;
new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(prix); // "1 234,50 €"
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(prix); // "$1,234.50"
new Intl.DateTimeFormat("fr-FR", { dateStyle: "long" }).format(new Date());
// "25 janvier 2026"
// Les durées relatives, sans dépendance non plus :
new Intl.RelativeTimeFormat("fr", { numeric: "auto" }).format(-3, "day");
// "il y a 3 jours"
// Et le tri, qui n'est PAS l'ordre des codes de caractères :
["Zoé", "Émile", "Alice"].sort(new Intl.Collator("fr").compare);
// ["Alice", "Émile", "Zoé"] — un sort() nu placerait "Émile" en dernierTrois pièges à connaître :
- La devise n'est pas la langue. Un client belge qui lit en anglais paie
quand même en euros. Garde
localeetcurrencycomme deux variables distinctes. - Le fuseau horaire non plus.
Intl.DateTimeFormatutilise le fuseau du serveur si tu ne précises rien, ce qui produit des dates différentes entre le rendu serveur et le rendu client — et une erreur d'hydratation. Passe toujourstimeZoneexplicitement pour les dates fixes. Intl.Collatorpour tout tri de texte affiché.sort()compare des points de code ; il place les accents après le Z.
Quelle librairie en 2026 ?
| Librairie | Ce qu'elle apporte | Ce qu'elle coûte |
|---|---|---|
next-intl |
Pensé pour l'App Router, excellent support des Server Components | Configuration initiale un peu plus longue |
i18next |
L'écosystème le plus vaste, des plugins pour tout | Plus lourd, friction avec les RSC |
FormatJS |
Standardisé sur ICU, robuste sur les très gros catalogues | Plus verbeux |
| Aucune | Zéro dépendance, contrôle total | Pluriels et balisage riche à écrire soi-même |
Pour un projet neuf sur l'App Router, next-intl est le choix par défaut. Pour
un site vitrine à deux langues, l'approche sans librairie décrite plus haut
suffit largement — n'ajoute pas une dépendance pour charger deux fichiers JSON.
Interface traduite ≠ contenu traduit
Dernière distinction, et c'est celle qui coûte le plus cher quand on la découvre tard : les libellés d'interface et le contenu éditorial n'ont pas le même cycle de vie.
Les libellés vivent dans le code, changent avec les fonctionnalités et se traduisent par clé. Les articles, pages produit et mentions légales vivent ailleurs — CMS, fichiers MDX — changent sans déploiement, et peuvent exister dans une langue sans exister dans l'autre.
Mélanger les deux mène à des dictionnaires de 4 000 lignes que plus personne ne relit. Sépare-les dès le départ :
src/i18n/ # libellés d'interface, par clé
content/fr/ # contenu éditorial français
content/en/ # contenu éditorial anglaisEt prévois le cas de l'article qui n'existe pas encore dans la langue demandée : soit une redirection vers la version disponible avec une mention explicite, soit un 404 propre. Le pire est la page à moitié traduite.
En résumé
L'internationalisation se joue à cinq endroits, et quatre d'entre eux sont invisibles à l'écran :
- Le routage — la langue vit dans l'URL, sinon le SEO ne suit pas.
- Le typage — une clé manquante doit casser le build, pas la page.
- Les métadonnées —
lang,hreflangréciproques, canonique,x-default. - Le formatage —
Intlfait le travail, gratuitement, mieux que toi. - La séparation — l'interface et le contenu éditorial ne suivent pas le même cycle.
Un dernier conseil : dès que le catalogue dépasse quelques dizaines de clés, branche tes fichiers de traduction sur un outil comme Phrase ou Lokalise. Les traducteurs travaillent sans toucher au code, et tu arrêtes de relire des diffs de JSON.