L’internationalisation de Next.js a beaucoup changé avec l’App Router, et une bonne partie de la confusion vient du fait que les conseils écrits pour le Pages Router restent le premier résultat de recherche.
La configuration i18n intégrée dans next.config.js ne fait rien dans l’App Router. C’était une fonctionnalité du Pages Router. Dans l’App Router, vous construisez le routage vous-même avec un segment dynamique, ce qui demande plus de travail et est considérablement plus flexible.
Ce guide couvre l’App Router avec next-intl, la bibliothèque la plus utilisée pour cela.
Routage
Placez un segment [locale] en haut du répertoire app :
app/
[locale]/
layout.tsx
page.tsx
dashboard/
page.tsx
Ainsi, /en/dashboard et /fr/dashboard résolvent vers le même composant avec un param locale différent.
Le middleware gère la détection et redirige un chemin nu vers un chemin préfixé :
// middleware.ts
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
locales: ["en", "fr", "de"],
defaultLocale: "en",
localePrefix: "as-needed",
});
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
localePrefix: "as-needed" sert la locale par défaut sans préfixe (/dashboard) et les autres préfixées (/fr/dashboard). Cela garde vos URLs anglaises canoniques stables, ce qui compte si le site est déjà bien classé.
Faites attention au matcher. Un matcher qui capture les routes /api ou les assets statiques les redirigera vers un préfixe de locale et les cassera, et le symptôme — des images qui font des 404 après l’ajout de l’i18n — ne fait pas immédiatement penser au middleware.
Mise en place
// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ requestLocale }) => {
const locale = (await requestLocale) ?? "en";
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Notez <html lang={locale}>. Cela pilote la prononciation par lecteur d’écran, les invites de traduction du navigateur et la sélection de police pour le CJK — ce n’est pas de la décoration.
Composants serveur et client
C’est la partie qui vaut la peine d’être bien traitée, car elle déterminera la taille de votre bundle.
Les composants serveur lisent les messages directement, et rien n’est envoyé au navigateur :
import { getTranslations } from "next-intl/server";
export default async function Dashboard() {
const t = await getTranslations("dashboard");
return <h1>{t("title")}</h1>;
}
Les composants client utilisent le hook, et leurs messages doivent être fournis :
"use client";
import { useTranslations } from "next-intl";
export function SaveButton() {
const t = useTranslations("actions");
return <button>{t("save")}</button>;
}
Le NextIntlClientProvider par défaut ci-dessus transmet tous les messages au client, ce qui va à l’encontre du but recherché. Réduisez-le à ce dont les composants client ont réellement besoin :
import { pick } from "lodash";
<NextIntlClientProvider messages={pick(messages, ["actions", "forms"])}>
Traduisez côté serveur autant que possible. Une page entièrement composée de composants serveur n’envoie aucun JSON de messages du tout.
Messages
Du JSON imbriqué, organisé en espaces de noms par fonctionnalité :
{
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back, {name}"
},
"messages": {
"count": "{count, plural, =0 {No messages} one {# message} other {# messages}}"
}
}
next-intl utilise ICU MessageFormat — accolades simples, catégories de pluriel CLDR nommées, select pour le genre, et formatage de nombres et de dates basé sur des skeletons. C’est un avantage réel par rapport à la syntaxe maison plus simple d’i18next, en particulier pour les langues nécessitant quatre ou six formes de pluriel.
Le texte enrichi garde le markup hors de la traduction :
t.rich("terms", {
link: (chunks) => <Link href="/tos">{chunks}</Link>,
});
{ "terms": "Read our <link>terms of service</link> before continuing." }
Le traducteur déplace <link>…</link> où la grammaire l’exige ; le href reste dans le code.
Metadata et SEO
Les objets metadata statiques ne peuvent pas être localisés — ils sont évalués sans params. Utilisez la forme asynchrone :
export async function generateMetadata({ params }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
return {
title: t("title"),
description: t("description"),
alternates: {
canonical: `/${locale}`,
languages: { en: "/en", fr: "/fr", "x-default": "/en" },
},
};
}
Le bloc alternates.languages émet les balises hreflang. Ne déclarez que les locales qui ont réellement du contenu traduit — pointer hreflang="fr" vers une page servant de l’anglais est pire que de n’en émettre aucune, car cela indique aux moteurs de recherche qu’une traduction existe alors que ce n’est pas le cas.
Ajoutez generateStaticParams pour que les routes de locale soient générées statiquement :
export function generateStaticParams() {
return ["en", "fr", "de"].map((locale) => ({ locale }));
}
Formatage
const format = useFormatter();
format.number(total, { style: "currency", currency: "EUR" });
format.dateTime(createdAt, { dateStyle: "medium" });
format.relativeTime(updatedAt);
Ces fonctions enveloppent Intl, donc le résultat suit la locale active. Ne pré-formatez jamais un nombre ou une date en chaîne pour l’interpoler ensuite.
Attention aux désynchronisations d’hydratation sur les temps relatifs et tout ce qui dérive de l’horloge courante : le serveur rend une valeur, le client une autre. Passez une heure de référence explicite, ou ne rendez ces éléments que côté client.
Synchroniser les traductions
Les fichiers de messages sont du simple JSON imbriqué :
wti push # messages/en.json envoyé
wti pull # fichiers de messages traduits reçus en retour
wti diff # ce que changerait un push
Push au merge sur votre branche désignée, pull chaque nuit dans une pull request. Voir les flux de travail de localisation basés sur Git.
Sécurité de type
Dérivez le type des clés à partir de vos messages anglais pour qu’une faute de frappe soit une erreur de build :
// global.d.ts
import type en from "./messages/en.json";
declare module "next-intl" {
interface AppConfig {
Messages: typeof en;
}
}
L’autocomplétion liste alors les clés disponibles, et retirer une clé du JSON fait remonter chaque site d’appel qui l’utilise. Sur un projet où les traductions sont modifiées hors du dépôt, cela vaut plus cher que d’habitude — cela signifie que le changement d’un fichier par un traducteur ne peut pas silencieusement casser les hypothèses du build sans que la vérification de type ne le détecte.
Génération statique et revalidation
Les routes de locale sont des routes ordinaires, donc elles se génèrent statiquement. La conséquence à anticiper est que les traductions sont figées au moment du build : récupérer de nouvelles traductions depuis votre plateforme ne change pas une page statique déployée avant qu’elle ne soit rebuild.
Deux façons de gérer cela. Rebuild à chaque changement de traduction, en faisant en sorte que votre pull request wti pull nocturne déclenche un déploiement quand elle est mergée — simple, et le délai est d’un jour au maximum. Ou utilisez revalidate sur les routes concernées pour que les pages se rafraîchissent sur un minuteur sans rebuild complet.
Ce que vous voulez dépend de la vitesse à laquelle les traductions doivent apparaître. Pour la plupart des produits, la voie de la pull request est la bonne : des traductions qui arrivent via la revue de code est une fonctionnalité, pas une latence à faire disparaître.
Tests
Effectuez le rendu avec un vrai provider plutôt qu’en mockant useTranslations — mocker cache les échecs qui valent la peine d’être détectés :
import { NextIntlClientProvider } from "next-intl";
import messages from "../messages/en.json";
render(
<NextIntlClientProvider locale="en" messages={messages}>
<Dashboard />
</NextIntlClientProvider>
);
Vérifiez le comportement plutôt que le texte traduit. Ajoutez ensuite une vérification CI de complétude entre locales, et une autre vérifiant que chaque route de generateStaticParams a un fichier de messages correspondant — une locale listée dans le middleware mais sans son JSON produit une erreur à l’exécution sur une page que personne ne visite jusqu’à ce qu’un client le fasse.
Erreurs courantes
- Suivre les conseils du Pages Router. La clé
i18ndenext.config.jsest inerte dans l’App Router. - Un matcher de middleware qui capture les routes API ou les fichiers statiques.
- Transmettre tous les messages à
NextIntlClientProvider, en envoyant tout le catalogue au navigateur. - Des objets
metadatastatiques, qui ne peuvent pas être localisés. hreflangpour des locales sans traductions réelles.langmanquant sur<html>.- Des désynchronisations d’hydratation issues d’un formatage dérivé de l’horloge.
Frequently asked questions
- L’App Router de Next.js a-t-il un i18n intégré ?
- Non. La configuration de routage i18n intégrée dans next.config.js ne s’est jamais appliquée qu’au Pages Router et ne fait rien dans l’App Router. L’internationalisation de l’App Router se fait avec un segment de route dynamique [locale] plus une bibliothèque telle que next-intl.
- Comment structurer les routes de locale dans Next.js ?
- Placez un segment dynamique [locale] en haut du répertoire app, si bien que app/[locale]/page.tsx sert /en et /fr. Utilisez un middleware pour détecter la locale du visiteur et rediriger un chemin nu vers celui préfixé.
- Comment traduire les metadata dans Next.js ?
- Exportez une fonction generateMetadata asynchrone qui reçoit le param locale, chargez les messages pour cette locale, et renvoyez le titre et la description traduits. Les objets metadata statiques ne peuvent pas être localisés car ils sont évalués sans params.
- Les traductions doivent-elles être chargées côté serveur ou côté client ?
- Côté serveur autant que possible. Les composants serveur peuvent lire les messages directement sans les envoyer au navigateur, si bien que seul le sous-ensemble nécessaire aux composants client interactifs traverse le réseau — ce qui est le principal avantage de taille de bundle de l’App Router pour l’i18n.
Keep reading
-
React i18next : configuration et flux de travail
Configurer i18next dans une application React : namespaces, pluriels, interpolation, Trans pour le balisage imbriqué, chargement différé, et synchronisation des fichiers de traduction.
-
Qu’est-ce qu’une locale ? Les balises de langue expliquées (BCP 47)
Une locale est plus qu’une langue. Voici ce qu’elle contrôle — dates, nombres, devises, tri, pluriels — et comment les balises de langue BCP 47 sont construites et mises en correspondance.
-
ICU MessageFormat : un guide pratique
La syntaxe des pluriels, de la sélection par genre, du formatage des nombres et des dates dans une seule chaîne de message — avec les règles d’échappement et les erreurs que font systématiquement les traducteurs.
-
Traduire des fichiers JSON (documentation)
Comment WebTranslateIt analyse les fichiers de messages JSON imbriqués utilisés par les bibliothèques i18n de Next.js.
Translate your app without the spreadsheet round-trip
WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.