Aller au contenu
Lantorian

Next.js11 min de lecture

Internationalisation FR/EN

Nos applications parlent français et anglais. Aucun texte n'est écrit en dur : tout vit dans des fichiers JSON, un par langue et par écran, chargés par next-intl.

Comment une page trouve sa langue

La langue est dans l'URL (/fr/…, /en/…). Le proxy la détecte, request.ts charge le bon JSON, et chaque composant lit ses textes par clé. Change la langue ou le nombre de factures pour voir les pluriels et les formats s'adapter.

Factures en retard2
  1. 1URL

    /fr/invoices
  2. 2proxy.ts

    locale = "fr"
  3. 3messages/fr|en

    {
      "invoices": {
        "title": "Factures",
        "overdue": "{count, plural, =0 {Aucune f…"
      }
    }
  4. 4Composant

    t('title')

Mis à jour le 16 septembre 2026

Factures

Bonjour Aina, voici le point du jour.

2 factures en retardMontant dû : 2 499,00 €
Ce rendu utilise réellement next-intl et deux fichiers JSON de ce site. Les pluriels, dates et montants suivent les règles de chaque langue.

Mise en place

Cinq fichiers, une seule fois par projet. Copie-les tels quels.

  1. 1

    Installer next-intl

    terminal
    npm install next-intl
  2. 2

    Déclarer les langues

    src/i18n/routing.ts
    import { defineRouting } from 'next-intl/routing';
    
    export const routing = defineRouting({
      locales: ['fr', 'en'],
      defaultLocale: 'fr',
      localePrefix: 'always', // /fr/invoices, /en/invoices
    });
  3. 3

    Charger les JSON de la langue demandée

    src/i18n/request.ts
    import { hasLocale } from 'next-intl';
    import { getRequestConfig } from 'next-intl/server';
    import { routing } from './routing';
    
    const namespaces = ['common', 'auth', 'invoices', 'validation'] as const; 
    
    export default getRequestConfig(async ({ requestLocale }) => {
      const requested = await requestLocale;
      const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
    
      // One JSON file per screen: messages/fr/invoices.json -> t('invoices.title')
      const entries = await Promise.all(
        namespaces.map(async (ns) => [ns, (await import(`../../messages/${locale}/${ns}.json`)).default] as const),
      );
    
      return {
        locale,
        messages: Object.fromEntries(entries),
        timeZone: 'Europe/Paris',
      };
    });
  4. 4

    Brancher le proxy, le plugin et la navigation

    src/proxy.ts
    import createMiddleware from 'next-intl/middleware';
    import { routing } from './i18n/routing';
    
    export default createMiddleware(routing);
    
    export const config = {
      // Everything except API routes, Next.js internals and static files
      matcher: '/((?!api|_next|_vercel|.*\\..*).*)',
    };
  5. 5

    Valider la langue dans le layout

    src/app/[locale]/layout.tsx
    import { notFound } from 'next/navigation';
    import { hasLocale, NextIntlClientProvider } from 'next-intl';
    import { setRequestLocale } from 'next-intl/server';
    import { routing } from '@/i18n/routing';
    
    export function generateStaticParams() {
      return routing.locales.map((locale) => ({ locale }));
    }
    
    export default async function LocaleLayout({ children, params }: LayoutProps<'/[locale]'>) {
      const { locale } = await params;
      if (!hasLocale(routing.locales, locale)) notFound(); 
      setRequestLocale(locale); // enables static rendering
    
      return (
        <html lang={locale}>
          <body>
            <NextIntlClientProvider>{children}</NextIntlClientProvider>
          </body>
        </html>
      );
    }

Organiser les fichiers JSON

Un fichier par écran ou domaine (invoices.json), un dossier par langue. Les clés sont identiques dans fr et en ; seules les valeurs changent.

messages/fr/invoices.json
{
  "title": "Factures",
  "empty": {
    "title": "Aucune facture pour l'instant",
    "action": "Créer une facture"
  },
  "overdue": "{count, plural, =0 {Aucune facture en retard} one {# facture en retard} other {# factures en retard}}",
  "total": "Total : {amount, number, ::currency/EUR}",
  "updated": "Mis à jour le {date, date, long}",
  "terms": "J'accepte les <link>conditions générales</link>",
  "status": {
    "draft": "Brouillon",
    "sent": "Envoyée",
    "paid": "Payée"
  }
}
  • Clés en camelCase, imbriquées par zone de l'écran : empty.title, table.columns.total.
  • Pluriels, nombres et dates en ICU dans la valeur, jamais calculés dans le composant.
  • Balises riches (lien, gras) déclarées dans le texte et rendues avec t.rich.
  • common.json pour les textes partagés : boutons, erreurs génériques, navigation.

Utiliser les traductions

Dans un Server Component : await getTranslations(). Dans un Client Component : useTranslations(). Les dates et nombres passent par useFormatter.

src/app/[locale]/(app)/invoices/page.tsx
import { getTranslations } from 'next-intl/server';

export async function generateMetadata({ params }: PageProps<'/[locale]/invoices'>) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'invoices' });
  return { title: t('title') };
}

export default async function InvoicesPage() {
  const t = await getTranslations('invoices');
  const overdueCount = await countOverdueInvoices();

  return (
    <header>
      <h1>{t('title')}</h1>
      <p>{t('overdue', { count: overdueCount })}</p>
    </header>
  );
}

À éviter

invoice-header.tsx
<p>{t('you_have')} {count} {count > 1 ? t('invoices') : t('invoice')}</p>

<button>Enregistrer</button>

<p>{date.toLocaleDateString()}</p>

À faire

invoice-header.tsx
<p>{t('overdue', { count })}</p>

<button>{t('actions.save')}</button>

<p>{format.dateTime(date, { dateStyle: 'long' })}</p>

Le sélecteur de langue

src/components/layout/locale-switcher.tsx
'use client';

import { useLocale, useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { usePathname, useRouter } from '@/i18n/navigation';
import { routing } from '@/i18n/routing';

export function LocaleSwitcher() {
  const t = useTranslations('common');
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();
  const [pending, startTransition] = useTransition();

  return (
    <div role="group" aria-label={t('language')} aria-busy={pending}>
      {routing.locales.map((l) => (
        <button
          key={l}
          type="button"
          aria-pressed={l === locale}
          onClick={() => startTransition(() => router.replace(pathname, { locale: l }))}
        >
          {l.toUpperCase()}
        </button>
      ))}
    </div>
  );
}

Clés typées et vérifiées

On déclare le type des messages à partir des fichiers fr : une clé mal orthographiée devient une erreur TypeScript, avec autocomplétion dans l'éditeur.

src/global.d.ts
// src/global.d.ts
import type { routing } from '@/i18n/routing';
import type common from '../messages/fr/common.json';
import type invoices from '../messages/fr/invoices.json';

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

// t('invoices.titel') -> TypeScript error: the key doesn't exist

Un script de CI vérifie que fr et en ont exactement les mêmes clés. Il bloque la PR si une traduction manque.

scripts/check-i18n.mjs
// scripts/check-i18n.mjs (this site uses it too), run in CI: npm run i18n:check
const flatten = (obj, prefix = '') =>
  Object.entries(obj).flatMap(([key, value]) =>
    value && typeof value === 'object' ? flatten(value, `${prefix}${key}.`) : [`${prefix}${key}`],
  );

for (const file of readdirSync('messages/fr')) {
  const fr = new Set(flatten(load('fr', file)));
  const en = new Set(flatten(load('en', file)));
  for (const k of fr) if (!en.has(k)) fail(`en/${file} is missing "${k}"`);
  for (const k of en) if (!fr.has(k)) fail(`fr/${file} is missing "${k}"`);
}