Aller au contenu
Hugo Delacour
Tous les articles

i18n & Next.js : traduire sans casser le SEO ni l'expérience développeur

Traduire une application n'est pas remplacer des chaînes : c'est une décision d'architecture qui touche le routage, les métadonnées, le typage et le formatage. Le parcours complet sur l'App Router, pièges compris.

15 juillet 202613 min
  • i18n
  • Next.js
  • TypeScript
  • SEO

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.tsx

Pré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.

src/middleware.ts
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.redirect renvoie 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.

components/locale-switcher.tsx
"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.

src/i18n/dictionaries.ts
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.

app/[locale]/page.tsx
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.

scripts/check-translations.ts
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.

src/i18n/plural.ts
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 pluriel

Si 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.

src/i18n/fr.json
{
  "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.

app/[locale]/layout.tsx
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.

app/[locale]/layout.tsx
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.

app/sitemap.ts
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 dernier

Trois 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 locale et currency comme deux variables distinctes.
  • Le fuseau horaire non plus. Intl.DateTimeFormat utilise 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 toujours timeZone explicitement pour les dates fixes.
  • Intl.Collator pour 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 anglais

Et 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 :

  1. Le routage — la langue vit dans l'URL, sinon le SEO ne suit pas.
  2. Le typage — une clé manquante doit casser le build, pas la page.
  3. Les métadonnéeslang, hreflang réciproques, canonique, x-default.
  4. Le formatageIntl fait le travail, gratuitement, mieux que toi.
  5. 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.