Changer le thème

Guide complet de l'internationalisation Next.js : bonnes pratiques avec next-intl

Easton editorial illustration: build pipeline conveyor

L’année dernière, j’ai repris un projet Next.js multilingue. Les réglages i18n dans la config m’ont un peu décontenancé au départ. En creusant la doc, j’ai compris qu’App Router et Pages Router n’ont pas du tout la même logique côté internationalisation. Une semaine de galère plus tard, next-intl tournait — avec pas mal de pièges au passage.

Aujourd’hui, on voit comment internationaliser Next.js, surtout avec next-intl sous App Router, proprement et sans prise de tête.

Pourquoi choisir next-intl ?

Vous vous demandez peut-être : Next.js n’a pas déjà l’i18n intégrée ? Si, à l’époque Pages Router. Avec App Router, cette fonctionnalité a été retirée.

La recommandation officielle : passer par une bibliothèque tierce. Et next-intl en est l’une des plus populaires.

Les atouts de next-intl :

  • Support natif App Router — conçu pour App Router, agréable au quotidien
  • Typage sûr — avec TypeScript, contrôle des clés de traduction
  • Routage flexible — sous-chemins, domaines, cookies, etc.
  • Fonctions avancées — pluriels, dates, nombres, texte enrichi
  • Bonnes perfs — compatible Server Components, rendu statique

Comparé aux autres options, la doc next-intl est claire ; la montée en charge reste raisonnable.

Configuration de base : partir de zéro

1. Installer les dépendances

Commencez par installer next-intl :

npm install next-intl
# ou
pnpm add next-intl
# ou
yarn add next-intl

2. Créer les fichiers de traduction

À la racine du projet, créez un dossier messages (ou locales), puis un JSON par langue :

messages/
├── en.json
├── zh.json
└── ja.json

messages/zh.json :

{
  "HomePage": {
    "title": "欢迎来到我的网站",
    "description": "这是一个支持多语言的 Next.js 应用"
  },
  "Navigation": {
    "home": "首页",
    "about": "关于",
    "contact": "联系我们"
  }
}

messages/en.json :

{
  "HomePage": {
    "title": "Welcome to My Website",
    "description": "This is a multilingual Next.js application"
  },
  "Navigation": {
    "home": "Home",
    "about": "About",
    "contact": "Contact Us"
  }
}

La structure imbriquée n’est pas obligatoire, mais regrouper par page ou composant simplifie la maintenance.

3. Configurer i18n.ts

Créez i18n.ts (ou i18n/config.ts) pour déclarer les langues supportées :

import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`./messages/${locale}.json`)).default
}));

Cette config indique à next-intl où charger les traductions. Le paramètre locale est extrait automatiquement de l’URL.

4. Créer le middleware

À la racine, middleware.ts gère le routage multilingue :

import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
  // Langues supportées
  locales: ['en', 'zh', 'ja'],

  // Langue par défaut
  defaultLocale: 'zh',

  // Afficher la langue par défaut dans l'URL ?
  localePrefix: 'as-needed'
});

export const config = {
  // Toutes les routes sauf api, _next/static, _next/image, favicon.ico
  matcher: ['/', '/(zh|en|ja)/:path*', '/((?!api|_next|_next/static|_next/image|favicon.ico).*)']
};

Options de localePrefix :

  • 'always' — préfixe pour toutes les langues, y compris la défaut (/zh/about, /en/about)
  • 'as-needed' — pas de préfixe pour la langue par défaut (/about, /en/about)
  • 'never' — aucun préfixe (langue identifiée autrement, ex. domaine)

J’utilise souvent 'as-needed' : URLs plus propres pour les utilisateurs de la langue par défaut.

5. Réorganiser le dossier app

Étape cruciale : placer toutes les routes sous [locale] :

Avant :

app/
├── page.tsx
├── about/
│   └── page.tsx
└── layout.tsx

Après :

app/
├── [locale]/
│   ├── page.tsx
│   ├── about/
│   │   └── page.tsx
│   └── layout.tsx
└── layout.tsx (optionnel, config globale)

6. Configurer le layout racine

Dans app/[locale]/layout.tsx :

import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';

const locales = ['en', 'zh', 'ja'];

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

export default async function LocaleLayout({
  children,
  params: { locale }
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {
  // Valider la langue
  if (!locales.includes(locale)) {
    notFound();
  }

  const messages = await getMessages();

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

Avec le rendu statique, generateStaticParams indique à Next.js quelles locales pré-générer.

Utiliser les traductions dans les composants

Une fois la config en place, les traductions sont disponibles partout.

Dans un Server Component

import { useTranslations } from 'next-intl';

export default function HomePage() {
  const t = useTranslations('HomePage');

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </div>
  );
}

Le paramètre de useTranslations est l’espace de noms (clé racine du JSON). Sans paramètre, utilisez le chemin complet : t('HomePage.title').

Dans un Client Component

Même API côté client :

'use client';

import { useTranslations } from 'next-intl';

export default function Navigation() {
  const t = useTranslations('Navigation');

  return (
    <nav>
      <a href="/">{t('home')}</a>
      <a href="/about">{t('about')}</a>
      <a href="/contact">{t('contact')}</a>
    </nav>
  );
}

Server et Client partagent la même ergonomie — un vrai plus de next-intl.

Routage multilingue

Obtenir la langue courante

import { useLocale } from 'next-intl';

export default function LanguageSwitcher() {
  const locale = useLocale();

  return <div>Langue actuelle : {locale}</div>;
}

Créer un sélecteur de langue

Indispensable sur tout site i18n :

'use client';

import { useLocale } from 'next-intl';
import { usePathname, useRouter } from 'next/navigation';

export default function LanguageSwitcher() {
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();

  const switchLanguage = (newLocale: string) => {
    const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
    router.push(newPath);
  };

  return (
    <select value={locale} onChange={(e) => switchLanguage(e.target.value)}>
      <option value="zh">中文</option>
      <option value="en">English</option>
      <option value="ja">日本語</option>
    </select>
  );
}

Petit piège : sur la langue par défaut (ex. zh), l’URL peut être /about sans préfixe ; en passant à l’anglais, il faut /en/about. Version corrigée :

const switchLanguage = (newLocale: string) => {
  let path = pathname;
  if (pathname.startsWith(`/${locale}`)) {
    path = pathname.substring(locale.length + 1);
  }

  const newPath = newLocale === 'zh' ? path : `/${newLocale}${path}`;
  router.push(newPath);
};

next-intl fournit un Link qui gère les préfixes de locale :

import { Link } from '@/navigation'; // à configurer d'abord

<Link href="/about">
  {t('about')}
</Link>

Configurez navigation.ts :

import { createSharedPathnamesNavigation } from 'next-intl/navigation';

export const locales = ['en', 'zh', 'ja'] as const;
export const localePrefix = 'as-needed';

export const { Link, redirect, usePathname, useRouter } =
  createSharedPathnamesNavigation({ locales, localePrefix });

Link, useRouter, etc. gèrent alors automatiquement les chemins localisés.

Fonctionnalités avancées

1. Traductions paramétrées

{
  "welcome": "欢迎回来,{username}!",
  "items": "你有 {count} 个新消息"
}

Usage :

const t = useTranslations();

<p>{t('welcome', { username: 'John' })}</p>
<p>{t('items', { count: 5 })}</p>

2. Gestion des pluriels

messages/en.json :

{
  "messages": {
    "one": "You have {count} message",
    "other": "You have {count} messages"
  }
}

Usage :

t('messages', { count: 1 })  // "You have 1 message"
t('messages', { count: 5 })  // "You have 5 messages"

Le chinois n’a pas de pluriel grammatical :

messages/zh.json :

{
  "messages": "你有 {count} 条消息"
}

3. Formatage dates et nombres

import { useFormatter } from 'next-intl';

export default function DateExample() {
  const format = useFormatter();
  const now = new Date();

  return (
    <div>
      <p>{format.dateTime(now, { dateStyle: 'full' })}</p>
      {/* zh : 2025年12月25日星期三 */}
      {/* en : Wednesday, December 25, 2025 */}

      <p>{format.number(1234567.89, { style: 'currency', currency: 'CNY' })}</p>
      {/* zh : ¥1,234,567.89 */}
      {/* en : CN¥1,234,567.89 */}
    </div>
  );
}

4. Texte enrichi

messages/zh.json :

{
  "richText": "我同意<terms>服务条款</terms>和<privacy>隐私政策</privacy>"
}

Usage :

import { useTranslations } from 'next-intl';

export default function Agreement() {
  const t = useTranslations();

  return (
    <p>
      {t.rich('richText', {
        terms: (chunks) => <a href="/terms">{chunks}</a>,
        privacy: (chunks) => <a href="/privacy">{chunks}</a>
      })}
    </p>
  );
}

Bonnes pratiques de gestion des traductions

Quand le projet grossit, les JSON deviennent difficiles à maintenir. Quelques astuces :

1. Découper par module

messages/
├── zh/
│   ├── common.json      # Boutons, erreurs, etc.
│   ├── home.json
│   ├── about.json
│   └── auth.json
├── en/
│   ├── common.json
│   ├── home.json
│   ├── about.json
│   └── auth.json

Fusion dans i18n.ts :

import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => {
  const common = (await import(`./messages/${locale}/common.json`)).default;
  const home = (await import(`./messages/${locale}/home.json`)).default;
  const about = (await import(`./messages/${locale}/about.json`)).default;
  const auth = (await import(`./messages/${locale}/auth.json`)).default;

  return {
    messages: {
      common,
      home,
      about,
      auth
    }
  };
});

2. Typage TypeScript

types/i18n.ts :

import zh from '@/messages/zh.json';

type Messages = typeof zh;

declare global {
  interface IntlMessages extends Messages {}
}

tsconfig.json :

{
  "compilerOptions": {
    "types": ["./types/i18n"]
  }
}

Clé inexistante → erreur TypeScript. Très utile au quotidien.

3. Traductions communes

messages/zh/common.json :

{
  "actions": {
    "save": "保存",
    "cancel": "取消",
    "delete": "删除",
    "confirm": "确认",
    "edit": "编辑"
  },
  "status": {
    "success": "操作成功",
    "error": "操作失败",
    "loading": "加载中..."
  }
}
const t = useTranslations('common.actions');
<button>{t('save')}</button>

4. Outils de gestion

  • Tolgee — plateforme open source, édition en temps réel
  • Localazy — workflows automatisés
  • i18n Ally (extension VSCode) — édition inline dans l’IDE

Je m’appuie surtout sur i18n Ally : les traductions visibles pendant le code.

5. Traductions manquantes

// i18n.ts
export default getRequestConfig(async ({ locale }) => {
  const messages = (await import(`./messages/${locale}.json`)).default;
  const fallback = locale !== 'zh'
    ? (await import(`./messages/zh.json`)).default
    : {};

  return {
    messages: {
      ...fallback,
      ...messages
    }
  };
});

Traduction absente → repli automatique sur le chinois.

Optimisation des performances

1. Découpage du code

export default function AdminPage() {
  const t = useTranslations('admin'); // charge uniquement le namespace admin
  // ...
}

2. Génération statique

// app/[locale]/about/page.tsx
export const dynamic = 'force-static';

export function generateStaticParams() {
  return [
    { locale: 'zh' },
    { locale: 'en' },
    { locale: 'ja' }
  ];
}

3. Préchargement des traductions

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

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

  return <h1>{t('title')}</h1>;
}

Problèmes courants et solutions

1. Changement de langue sur routes dynamiques

Pour /blog/[slug], conserver le slug :

const switchLanguage = (newLocale: string) => {
  const segments = pathname.split('/').filter(Boolean);
  if (['zh', 'en', 'ja'].includes(segments[0])) {
    segments.shift();
  }
  if (newLocale !== 'zh' || localePrefix === 'always') {
    segments.unshift(newLocale);
  }
  router.push('/' + segments.join('/'));
};

2. SEO

// app/[locale]/layout.tsx
export async function generateMetadata({ params: { locale } }) {
  const t = await getTranslations({ locale, namespace: 'metadata' });

  return {
    title: t('title'),
    description: t('description'),
    alternates: {
      canonical: `https://example.com/${locale}`,
      languages: {
        'zh-CN': 'https://example.com/zh',
        'en-US': 'https://example.com/en',
        'ja-JP': 'https://example.com/ja'
      }
    }
  };
}

3. Détection de langue

// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { NextRequest } from 'next/server';

const intlMiddleware = createMiddleware({
  locales: ['en', 'zh', 'ja'],
  defaultLocale: 'zh',
  localeDetection: true
});

export default function middleware(request: NextRequest) {
  return intlMiddleware(request);
}

next-intl lit l’en-tête Accept-Language pour la première visite.

4. Mémoriser la préférence

const switchLanguage = (newLocale: string) => {
  document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`;
  router.push(newPath);
};

Le middleware next-intl relit ce cookie automatiquement.

Cas pratique : projet i18n complet

Structure d’un petit projet que j’ai livré :

├── app/
│   ├── [locale]/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── blog/
│   │       └── [slug]/
│   │           └── page.tsx
├── components/
│   ├── LanguageSwitcher.tsx
│   └── Navigation.tsx
├── messages/
│   ├── zh/
│   │   ├── common.json
│   │   └── blog.json
│   ├── en/
│   │   ├── common.json
│   │   └── blog.json
├── i18n.ts
├── middleware.ts
└── navigation.ts

navigation.ts :

import { createSharedPathnamesNavigation } from 'next-intl/navigation';

export const locales = ['zh', 'en'] as const;
export const localePrefix = 'as-needed';

export const { Link, redirect, usePathname, useRouter } =
  createSharedPathnamesNavigation({ locales, localePrefix });

components/Navigation.tsx :

'use client';

import { Link } from '@/navigation';
import { useTranslations } from 'next-intl';
import LanguageSwitcher from './LanguageSwitcher';

export default function Navigation() {
  const t = useTranslations('common.navigation');

  return (
    <nav className="flex items-center justify-between p-4">
      <div className="flex gap-4">
        <Link href="/">{t('home')}</Link>
        <Link href="/blog">{t('blog')}</Link>
        <Link href="/about">{t('about')}</Link>
      </div>
      <LanguageSwitcher />
    </nav>
  );
}

En production, le changement de langue était fluide, sans accroc.

Résumé

L’i18n Next.js sous App Router paraît complexe, mais avec next-intl, ça se tient :

  1. Config cœur : middleware + i18n.ts + dossier [locale]
  2. Traductions : hook useTranslations côté Server et Client
  3. Routage : Link et useRouter fournis par next-intl
  4. Fichiers : découpage par module + typage TypeScript
  5. Perfs : génération statique + chargement à la demande

Au début, middleware et routes dynamiques m’ont semblé tortueux. Après quelques projets, c’est devenu un réflexe.

Si vous préparez un site multilingue, testez next-intl : courbe d’apprentissage réelle, mais rentable sur la durée.

Ressources

J’espère que cet article vous évitera une partie de la galère sur l’i18n Next.js !

Configuration complète de l'internationalisation Next.js

De l'installation de next-intl au routage multilingue et à la gestion des fichiers de traduction

⏱️ Estimated time: 2 hr

  1. 1

    Step 1: Installer next-intl et configurer la base

    Installation :
    ```bash
    npm install next-intl
    ```

    Créer les fichiers de traduction :
    ```
    messages/
    zh.json
    en.json
    ```

    Configurer middleware.ts :
    ```ts
    import createMiddleware from 'next-intl/middleware'
    import { routing } from './i18n/routing'

    export default createMiddleware(routing)

    export const config = {
    matcher: ['/', '/(zh|en)/:path*']
    }
    ```

    Configurer app/[locale]/layout.tsx :
    ```tsx
    import { NextIntlClientProvider } from 'next-intl'
    import { getMessages } from 'next-intl/server'

    export default async function LocaleLayout({
    children,
    params: { locale }
    }) {
    const messages = await getMessages()

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

    Points clés :
    • Route dynamique [locale]
    • Fournir les traductions dans le layout
    • Middleware pour le changement de langue
  2. 2

    Step 2: Configurer le schéma de routage multilingue

    Option 1 : sous-chemins (recommandé)
    • URL : /zh/about, /en/about
    • Configuration simple
    • SEO friendly

    Option 2 : domaines
    • URL : zh.example.com, en.example.com
    • Plusieurs domaines à configurer
    • Aspect plus professionnel

    Option 3 : cookie
    • Langue via cookie
    • Pas de préfixe dans l'URL
    • Adapté aux audiences monolingues

    Config sous-chemins :
    ```ts
    // i18n/routing.ts
    export const routing = {
    locales: ['zh', 'en'],
    defaultLocale: 'zh'
    }
    ```

    Usage :
    ```tsx
    import { useTranslations } from 'next-intl'

    export function Page() {
    const t = useTranslations('common')
    return <h1>{t('title')}</h1>
    }
    ```

    Point clé : choisir le schéma adapté ; les sous-chemins suffisent pour la plupart des projets
  3. 3

    Step 3: Gérer les fichiers de traduction

    Créer les fichiers :
    ```json
    // messages/zh.json
    {
    "common": {
    "title": "欢迎",
    "description": "这是一个多语言网站"
    },
    "nav": {
    "home": "首页",
    "about": "关于"
    }
    }
    ```

    Utiliser les traductions :
    ```tsx
    import { useTranslations } from 'next-intl'

    export function Page() {
    const t = useTranslations('common')
    return (
    <div>
    <h1>{t('title')}</h1>
    <p>{t('description')}</p>
    </div>
    )
    }
    ```

    Typage sûr :
    ```ts
    // i18n/request.ts
    import { getRequestConfig } from 'next-intl/server'

    export default getRequestConfig(async ({ locale }) => ({
    messages: (await import(`../messages/${locale}.json`)).default
    }))
    ```

    Points clés :
    • Structure JSON imbriquée
    • Typage TypeScript
    • Extension VSCode i18n Ally

FAQ

Pourquoi App Router nécessite-t-il next-intl ?
Raison : App Router a retiré l'i18n intégrée de Pages Router.

Pages Router :
• i18n de routage intégrée
• Configuration i18n dans next.config.js
• Changement de langue automatique

App Router :
• Plus d'i18n intégrée
• Bibliothèque tierce requise
• next-intl est le choix le plus populaire

Atouts next-intl :
• Support natif App Router
• Typage sûr
• Routage flexible
• Pluriels, formatage de dates, etc.
• Excellentes performances

Conseil : sous App Router, privilégiez next-intl.
Quels schémas de routage next-intl propose-t-il ?
Trois options :

Option 1 : sous-chemins (recommandé)
• URL : /zh/about, /en/about
• Configuration simple
• SEO friendly
• Convient à la majorité des projets

Option 2 : domaines
• URL : zh.example.com, en.example.com
• Plusieurs domaines
• Plus professionnel
• Grands projets

Option 3 : cookie
• Langue via cookie
• Pas de préfixe URL
• Audiences monolingues
• Configuration plus complexe

Recommandation :
• Plupart des projets → sous-chemins
• Grands projets → domaines
• Besoins spécifiques → cookie
Comment configurer next-intl ?
Installation :
```bash
npm install next-intl
```

Fichiers de traduction :
```
messages/
zh.json
en.json
```

middleware.ts :
```ts
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)

export const config = {
matcher: ['/', '/(zh|en)/:path*']
}
```

app/[locale]/layout.tsx :
```tsx
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'

export default async function LocaleLayout({
children,
params: { locale }
}) {
const messages = await getMessages()

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

Points clés :
• Route [locale]
• Traductions dans le layout
• Middleware pour le changement de langue
Comment gérer les fichiers de traduction ?
Exemple messages/zh.json :
```json
{
"common": {
"title": "欢迎",
"description": "这是一个多语言网站"
},
"nav": {
"home": "首页",
"about": "关于"
}
}
```

Usage :
```tsx
import { useTranslations } from 'next-intl'

export function Page() {
const t = useTranslations('common')
return (
<div>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</div>
)
}
```

Typage :
```ts
import { getRequestConfig } from 'next-intl/server'

export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default
}))
```

Points clés :
• JSON imbriqué
• TypeScript pour la sécurité
• Extension i18n Ally

Conseil : organiser par module, éviter un fichier unique trop gros.
Quelles fonctionnalités next-intl prend-il en charge ?
Fonctions principales :
• Texte traduit (fonction t)
• Pluriels
• Formatage de dates
• Formatage de nombres
• Texte enrichi

Exemple :
```tsx
import { useTranslations, useFormatter } from 'next-intl'

export function Page() {
const t = useTranslations('common')
const format = useFormatter()

return (
<div>
<h1>{t('title')}</h1>
<p>{format.dateTime(new Date(), { dateStyle: 'long' })}</p>
<p>{format.number(1234.56, { style: 'currency', currency: 'USD' })}</p>
</div>
)
}
```

Atouts :
• Fonctions complètes
• Typage sûr
• Bonnes perfs
• Compatible Server Components

Conseil : exploitez pluriels et formatage pour une meilleure UX.
Comment implémenter le changement de langue ?
Avec Link :
```tsx
import { Link } from '@/i18n/navigation'

<Link href="/about" locale="en">
English
</Link>
<Link href="/about" locale="zh">
中文
</Link>
```

Avec useRouter :
```tsx
'use client'
import { useRouter, usePathname } from '@/i18n/navigation'

export function LanguageSwitcher() {
const router = useRouter()
const pathname = usePathname()

const switchLanguage = (locale: string) => {
router.replace(pathname, { locale })
}

return (
<button onClick={() => switchLanguage('en')}>
English
</button>
)
}
```

Points clés :
• Link et useRouter de next-intl
• Conserver le chemin, changer la locale
• Bonne expérience utilisateur

Conseil : placez le sélecteur dans la barre de navigation ou le pied de page.

10 min de lecture · Publié le: 25 déc. 2025 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog