Internationalisation Next.js

Internationaliser une application Next.js App Router : routage par segment de locale, traduction serveur et client, metadata, hreflang et synchronisation des fichiers de messages.

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

ts
// 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

ts
// 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,
  };
});
tsx
// 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 :

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

tsx
"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 :

tsx
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é :

json
{
  "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 :

tsx
t.rich("terms", {
  link: (chunks) => <Link href="/tos">{chunks}</Link>,
});
json
{ "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 :

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

tsx
export function generateStaticParams() {
  return ["en", "fr", "de"].map((locale) => ({ locale }));
}

Formatage

tsx
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é :

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

ts
// 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 :

tsx
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é i18n de next.config.js est 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 metadata statiques, qui ne peuvent pas être localisés.
  • hreflang pour des locales sans traductions réelles.
  • lang manquant 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

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.