Next.jsi18nnext-intlTypeScriptInternationalisation

i18n avec Next.js 16 : créer un site multilingue avec next-intl

Guide complet pour internationaliser un site Next.js 16 avec next-intl : routing, traductions typées, formatage et SEO multilingue.

AMAlexis Mouchon11 min de lecture

Vous avez un site Next.js qui cartonne en France et vous voulez l'ouvrir à l'international ? Ou vous démarrez un projet qui doit dès le départ servir français, anglais et espagnol ? L'internationalisation (i18n) est l'un des chantiers les plus sous-estimés en frontend : entre le routing par locale, les traductions, les formats de dates et de devises, le SEO multilingue et les Server Components, il y a vite de quoi se perdre.

Dans cet article, je vous montre comment mettre en place une i18n robuste avec Next.js 16 (App Router) et next-intl, la lib qui s'intègre proprement avec les Server Components, le streaming et le routing dynamique.

Pourquoi next-intl plutôt qu'autre chose ?

Le paysage des libs i18n pour React est saturé : react-i18next, lingui, formatjs, next-translate… Pourquoi je recommande next-intl sur Next.js 16 ?

L'alternative sérieuse est next-translate, mais elle est moins activement maintenue et son support des Server Components est plus bancal.

Installation et configuration de base

Je pars du principe que vous avez un projet Next.js 16 avec TypeScript et l'App Router. Si ce n'est pas le cas, vous pouvez consulter mon article sur les Server Actions Next.js 15 qui présente le setup de base.

Installation :

npm install next-intl

Créez un dossier messages/ à la racine pour stocker vos traductions :

messages/
├── fr.json
├── en.json
└── es.json

messages/fr.json :

{
  "Home": {
    "title": "Bienvenue sur mon site",
    "description": "Développeur web freelance basé en France",
    "cta": "Discutons de votre projet"
  },
  "Common": {
    "loading": "Chargement…",
    "error": "Une erreur est survenue"
  }
}

messages/en.json :

{
  "Home": {
    "title": "Welcome to my website",
    "description": "Freelance web developer based in France",
    "cta": "Let's talk about your project"
  },
  "Common": {
    "loading": "Loading…",
    "error": "An error occurred"
  }
}

Structure du projet avec routing par locale

next-intl recommande de placer toutes les pages dans un segment dynamique [locale]. Cela permet d'avoir des URLs comme /fr/blog/mon-article et /en/blog/my-article.

src/
├── app/
│   └── [locale]/
│       ├── layout.tsx
│       ├── page.tsx
│       └── blog/
│           └── [slug]/
│               └── page.tsx
├── i18n/
│   ├── routing.ts
│   └── request.ts
├── middleware.ts
└── messages/
    ├── fr.json
    └── en.json

1. Définir les locales et le routing

src/i18n/routing.ts :

import { defineRouting } from "next-intl/routing";
import { createNavigation } from "next-intl/navigation";

export const routing = defineRouting({
  locales: ["fr", "en", "es"] as const,
  defaultLocale: "fr",
  localePrefix: "as-needed", // /fr est optionnel pour la locale par défaut
});

export type Locale = (typeof routing.locales)[number];

// Wrappers typés pour la navigation
export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);

L'option localePrefix: "as-needed" est importante côté SEO : elle évite d'avoir /fr/blog ET /blog qui pointent vers le même contenu (contenu dupliqué = pénalité Google).

2. Configurer le middleware

src/middleware.ts :

import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";

export default createMiddleware(routing);

export const config = {
  // Match toutes les routes sauf les fichiers statiques et l'API
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};

Ce middleware fait deux choses : il détecte la locale du visiteur (cookie → header Accept-Language → defaultLocale) et il rewrite les URLs pour toujours tomber sur la bonne page. Si vous avez d'autres règles dans votre middleware (auth, redirections), il faudra composer comme je l'explique dans mon article sur le middleware Next.js App Router.

3. Charger les messages côté serveur

src/i18n/request.ts :

import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested)
    ? requested
    : routing.defaultLocale;

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});

Et dans next.config.ts :

import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");

const nextConfig: NextConfig = {
  // votre config
};

export default withNextIntl(nextConfig);

Utiliser les traductions dans vos pages

Layout racine localisé

src/app/[locale]/layout.tsx :

import { NextIntlClientProvider, hasLocale } from "next-intl";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;

  if (!hasLocale(routing.locales, locale)) {
    notFound();
  }

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider locale={locale}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

L'attribut lang={locale} sur <html> est crucial : Google et les lecteurs d'écran s'en servent.

Traduire dans un Server Component

import { getTranslations } from "next-intl/server";

export default async function HomePage() {
  const t = await getTranslations("Home");

  return (
    <main className="mx-auto max-w-3xl px-6 py-12">
      <h1 className="text-4xl font-bold">{t("title")}</h1>
      <p className="mt-4 text-lg text-gray-600">{t("description")}</p>
      <a
        href="#contact"
        className="mt-8 inline-block rounded-md bg-blue-600 px-6 py-3 text-white"
      >
        {t("cta")}
      </a>
    </main>
  );
}

Traduire dans un Client Component

"use client";

import { useTranslations } from "next-intl";

export function ContactForm() {
  const t = useTranslations("Common");

  return (
    <form>
      <button type="submit" disabled>
        {t("loading")}
      </button>
    </form>
  );
}

L'API est volontairement quasi-identique entre serveur et client. Vous changez juste l'import.

Typer les clés de traduction (le truc qui change la vie)

Sans typage, une faute de frappe dans une clé (t("titel") au lieu de t("title")) casse silencieusement votre UI — le texte affiché devient juste la clé brute. Avec next-intl, vous pouvez faire du type-safe i18n en quelques lignes.

global.d.ts à la racine :

import { routing } from "@/i18n/routing";
import messages from "@/../messages/fr.json";

declare module "next-intl" {
  interface AppConfig {
    Locale: (typeof routing.locales)[number];
    Messages: typeof messages;
  }
}

Résultat : votre IDE auto-complète les clés disponibles, et le build casse si vous référencez une clé inexistante. Plus aucune surprise en prod. Si vous découvrez TypeScript à fond, j'ai écrit un guide sur les Generics avancés qui complète très bien cette approche.

Formater dates, nombres et pluriels proprement

C'est souvent là où les sites se plantent : un prix affiché en 1234.56 € au lieu de 1 234,56 € en français, ou une date au format US sur un site français.

next-intl expose des helpers basés sur l'API Intl standard :

import { useFormatter, useTranslations } from "next-intl";

export function ProductCard({ price, releasedAt }: Props) {
  const format = useFormatter();
  const t = useTranslations("Product");

  return (
    <article>
      <p>
        {format.number(price, {
          style: "currency",
          currency: "EUR",
        })}
      </p>
      <p>
        {t("releasedOn", {
          date: format.dateTime(releasedAt, {
            dateStyle: "long",
          }),
        })}
      </p>
    </article>
  );
}

En français : 1 234,56 € et 8 mai 2026. En anglais : €1,234.56 et May 8, 2026. Aucune logique métier à écrire, l'API navigateur s'en occupe.

Pluriels et messages riches

Le format ICU MessageFormat permet de gérer pluriels et interpolations sans gymnastique :

messages/fr.json :

{
  "Cart": {
    "items": "{count, plural, =0 {Aucun article} one {# article} other {# articles}}"
  }
}
const t = useTranslations("Cart");
t("items", { count: 0 }); // "Aucun article"
t("items", { count: 1 }); // "1 article"
t("items", { count: 5 }); // "5 articles"

SEO multilingue : les balises qui font la différence

Avoir trois versions de votre site ne sert à rien si Google ne les indexe pas correctement. Trois choses à mettre en place :

1. Balises hreflang

Elles indiquent à Google les versions linguistiques d'une page. À ajouter dans le <head> via la metadata API :

import { Metadata } from "next";
import { getTranslations } from "next-intl/server";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: "Home" });

  return {
    title: t("title"),
    description: t("description"),
    alternates: {
      canonical: `/${locale}`,
      languages: {
        fr: "/fr",
        en: "/en",
        es: "/es",
        "x-default": "/fr",
      },
    },
  };
}

La valeur x-default est utilisée par Google quand il ne sait pas quelle langue servir.

2. Sitemap multilingue

Votre sitemap.ts doit lister chaque URL pour chaque locale :

import { MetadataRoute } from "next";
import { routing } from "@/i18n/routing";

const SITE_URL = "https://votresite.com";

export default function sitemap(): MetadataRoute.Sitemap {
  const pages = ["", "/blog", "/contact"];

  return pages.flatMap((page) =>
    routing.locales.map((locale) => ({
      url: `${SITE_URL}/${locale}${page}`,
      lastModified: new Date(),
      alternates: {
        languages: Object.fromEntries(
          routing.locales.map((l) => [l, `${SITE_URL}/${l}${page}`]),
        ),
      },
    })),
  );
}

3. Slugs traduits (SEO avancé)

Pour le top-niveau du SEO, traduisez aussi vos slugs : /fr/contact/en/contact, mais surtout /fr/blog/mon-article/en/blog/my-article. next-intl supporte ça via pathnames dans la config de routing :

export const routing = defineRouting({
  locales: ["fr", "en"] as const,
  defaultLocale: "fr",
  pathnames: {
    "/": "/",
    "/contact": {
      fr: "/contact",
      en: "/contact",
    },
    "/blog": {
      fr: "/articles",
      en: "/blog",
    },
  },
});

C'est un peu plus de boulot à maintenir, mais l'impact SEO est réel sur des marchés concurrentiels.

Erreurs classiques à éviter

Quelques pièges que je vois régulièrement chez mes clients :

Conclusion

Internationaliser un site Next.js correctement, ce n'est pas juste afficher du texte dans une autre langue : c'est repenser le routing, le SEO, le formatage, et la maintenance des traductions. next-intl rend ce chantier nettement moins douloureux grâce à son intégration profonde avec les Server Components et au typage strict des messages.

Le trio defineRouting + middleware + getTranslations/useTranslations couvre 90 % des cas d'usage. Pour les 10 % restants (pluriels complexes, slugs traduits, formatage métier), l'API ICU et les helpers useFormatter font le job sans ajouter de dépendance.

Si vous lancez un projet qui vise plusieurs marchés, mettez l'i18n en place dès le début. Rajouter une seconde langue après coup sur un site qui a 80 pages, c'est un chantier de plusieurs jours. La faire dès le début, c'est une heure de setup.


Vous avez un projet de site multilingue (vitrine, portfolio, e-commerce ou SaaS) et vous voulez le faire tourner sur une stack moderne et performante ? N'hésitez pas à me contacter pour en discuter — je vous accompagne du cadrage à la mise en production. Vous pouvez aussi m'écrire directement à contact@alexis-mouchon.fr.