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.
1URL
/fr/invoices2proxy.ts
locale = "fr"3messages/fr|en
{ "invoices": { "title": "Factures", "overdue": "{count, plural, =0 {Aucune f…" } }4Composant
t('title')
Mis à jour le 16 septembre 2026
Factures
Bonjour Aina, voici le point du jour.
Mise en place
Cinq fichiers, une seule fois par projet. Copie-les tels quels.
- 1
Installer next-intl
terminalnpm install next-intl - 2
Déclarer les langues
src/i18n/routing.tsimport { defineRouting } from 'next-intl/routing'; export const routing = defineRouting({ locales: ['fr', 'en'], defaultLocale: 'fr', localePrefix: 'always', // /fr/invoices, /en/invoices }); - 3
Charger les JSON de la langue demandée
src/i18n/request.tsimport { 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
Brancher le proxy, le plugin et la navigation
src/proxy.tsimport 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|.*\\..*).*)', };next.config.tsimport type { NextConfig } from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = {}; export default withNextIntl(nextConfig);src/i18n/navigation.tsimport { createNavigation } from 'next-intl/navigation'; import { routing } from './routing'; // Use these instead of next/link and next/navigation: they keep the locale export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing); - 5
Valider la langue dans le layout
src/app/[locale]/layout.tsximport { 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.
{
"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"
}
}{
"title": "Invoices",
"empty": {
"title": "No invoices yet",
"action": "Create an invoice"
},
"overdue": "{count, plural, =0 {No overdue invoices} one {# overdue invoice} other {# overdue invoices}}",
"total": "Total: {amount, number, ::currency/EUR}",
"updated": "Updated on {date, date, long}",
"terms": "I accept the <link>terms and conditions</link>",
"status": {
"draft": "Draft",
"sent": "Sent",
"paid": "Paid"
}
}- 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.
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>
);
}'use client';
import { useFormatter, useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
export function InvoiceSummary({ total, updatedAt, status }: Props) {
const t = useTranslations('invoices');
const format = useFormatter();
return (
<div>
<Badge>{t(`status.${status}`)}</Badge>
<p>{t('total', { amount: total / 100 })}</p>
<time dateTime={updatedAt.toISOString()}>{format.relativeTime(updatedAt)}</time>
<label>
<input type="checkbox" />
{t.rich('terms', {
link: (chunks) => <Link href="/terms">{chunks}</Link>,
})}
</label>
</div>
);
}À éviter
<p>{t('you_have')} {count} {count > 1 ? t('invoices') : t('invoice')}</p>
<button>Enregistrer</button>
<p>{date.toLocaleDateString()}</p>À faire
<p>{t('overdue', { count })}</p>
<button>{t('actions.save')}</button>
<p>{format.dateTime(date, { dateStyle: 'long' })}</p>Le sélecteur de langue
'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
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 existUn 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 (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}"`);
}