Internationalisation dans Next.js : implémentation à 11 locales
Construire pour un public mondial nécessite plus que simplement faire passer vos chaînes via Google Translate. Une mise en œuvre de l'internationalisation de la production implique une stratégie de routage, la prise en charge de la mise en page RTL (de droite à gauche), la résolution des paramètres régionaux côté serveur, l'organisation des clés de traduction, les signaux SEO hreflang et un flux de traduction durable. Si vous vous trompez, vous aurez soit des mises en page cassées pour les utilisateurs en arabe/ourdou, soit des balises hreflang manquantes qui divisent votre classement dans les recherches, soit un pipeline de traduction qui s'effondre sous le poids de la croissance du contenu.
Ce guide documente la configuration complète à 11 paramètres régionaux utilisée sur ECOSIRE.COM — anglais plus chinois, espagnol, arabe, portugais, français, allemand, japonais, turc, hindi et ourdou — construite sur next-intl v4.8. Chaque modèle ici est testé en production sur 5 577 fichiers de contenu MDX et 12 543 clés de traduction.
Points clés à retenir
- Utilisez
localePrefix: 'as-needed'— L'anglais n'a pas de préfixe, les autres langues obtiennent/zh/,/ar/, etc.- next-intl v4 utilise le modèle
routing.ts+navigation.ts— n'importez jamais depuisnext/navigationdirectement dans les composants compatibles avec les paramètres régionaux- L'arabe (
ar) et l'ourdou (ur) nécessitentdir="rtl"sur<html>et une police compatible RTL (Noto Sans Arabic)- Les clés
en.jsonDOIVENT être des objets imbriqués — les clés plates séparées par des points interrompent la résolution de l'espace de nomsgenerateMetadata()(jamaisexport const metadatastatique) avecalternates.languagespour hreflang sur chaque page- Les composants serveur utilisent
getTranslations('namespace'), les composants clients utilisentuseTranslations('namespace')- Les pages d'administration utilisent deux hooks de traduction :
tpour les clés spécifiques au module,tcpour les clésadmin.commonpartagées- Automatisez la traduction des nouvelles clés avec l'API Google Translate ; ne bloquez jamais le développement sur la traduction manuelle
Configuration du projet
pnpm add next-intl@4
Structure du répertoire pour chaque application :
src/
i18n/
routing.ts # defineRouting() — locale list, prefix strategy
navigation.ts # createNavigation() — locale-aware Link, useRouter
request.ts # getRequestConfig() — server-side message loading
messages/
en.json # Source of truth (~12,543 keys)
zh.json
es.json
ar.json
pt.json
fr.json
de.json
ja.json
tr.json
hi.json
ur.json
Configuration du routage
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const locales = [
'en', 'zh', 'es', 'ar', 'pt', 'fr', 'de', 'ja', 'tr', 'hi', 'ur',
] as const;
export type Locale = (typeof locales)[number];
export const rtlLocales: Locale[] = ['ar', 'ur'];
export const localeNames: Record<Locale, string> = {
en: 'English',
zh: '中文',
es: 'Español',
ar: 'العربية',
pt: 'Português',
fr: 'Français',
de: 'Deutsch',
ja: '日本語',
tr: 'Türkçe',
hi: 'हिन्दी',
ur: 'اردو',
};
export const routing = defineRouting({
locales,
defaultLocale: 'en',
localePrefix: 'as-needed', // /en/blog → /blog, /zh/blog → /zh/blog
});
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, useRouter, usePathname, getPathname } =
createNavigation(routing);
Intergiciel / Proxy
next-intl v4 utilise une fonction middleware pour la détection et le routage des paramètres régionaux :
// src/proxy.ts (not middleware.ts — naming matters for Next.js 16)
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { NextRequest, NextResponse } from 'next/server';
const intlMiddleware = createMiddleware(routing);
export function middleware(request: NextRequest) {
// Auth protection for dashboard routes
const isDashboard = request.nextUrl.pathname.includes('/dashboard');
const isPortal = request.nextUrl.pathname.includes('/portal');
const authCookie = request.cookies.get('ecosire_auth');
if ((isDashboard || isPortal) && !authCookie) {
const loginUrl = new URL('/auth/login', request.url);
// Prevent open redirect: only allow relative redirect paths
const from = request.nextUrl.pathname;
if (from.startsWith('/') && !from.startsWith('//')) {
loginUrl.searchParams.set('from', from);
}
return NextResponse.redirect(loginUrl);
}
return intlMiddleware(request);
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|.*\\..*|_vercel|favicon.ico).*)',
],
};
Chargement des messages côté serveur
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate locale is supported; fall back to default
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
Disposition des paramètres régionaux avec RTL et prise en charge des polices
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing, rtlLocales, type Locale } from '@/i18n/routing';
import { Noto_Sans, Noto_Sans_Arabic } from 'next/font/google';
const notoSans = Noto_Sans({ subsets: ['latin'], variable: '--font-sans' });
const notoArabic = Noto_Sans_Arabic({
subsets: ['arabic'],
variable: '--font-arabic',
});
export async function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata' });
return {
title: { template: `%s | ${t('siteName')}`, default: t('defaultTitle') },
other: { 'content-language': locale }, // For Bing/AI engine language detection
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) {
notFound();
}
const messages = await getMessages();
const isRtl = rtlLocales.includes(locale as Locale);
return (
<html
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
className={`${notoSans.variable} ${notoArabic.variable}`}
>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Organisation clé de traduction
La structure en.json doit utiliser des objets imbriqués, et non des clés plates séparées par des points :
// WRONG — flat keys break next-intl namespace resolution
{
"nav.home": "Home",
"nav.blog": "Blog",
"admin.common.save": "Save"
}
// CORRECT — nested objects
{
"nav": {
"home": "Home",
"blog": "Blog"
},
"admin": {
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"status": "Status"
},
"contacts": {
"title": "Contacts",
"addContact": "Add Contact",
"searchPlaceholder": "Search contacts..."
}
}
}
Composants du serveur
// Server component: use getTranslations (async)
import { getTranslations } from 'next-intl/server';
export default async function ServicesPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'services.odoo' });
return (
<main>
<h1>{t('hero.title')}</h1>
<p>{t('hero.description')}</p>
</main>
);
}
// generateMetadata must also fetch translations for locale-aware metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'services.odoo' });
return {
title: t('meta.title'),
description: t('meta.description'),
alternates: {
canonical: `https://ecosire.com${locale === 'en' ? '' : `/${locale}`}/services/odoo`,
languages: {
'x-default': 'https://ecosire.com/services/odoo',
en: 'https://ecosire.com/services/odoo',
zh: 'https://ecosire.com/zh/services/odoo',
ar: 'https://ecosire.com/ar/services/odoo',
// ... all 11 locales
},
},
};
}
Composants clients
// Client component: use useTranslations (synchronous)
'use client';
import { useTranslations } from 'next-intl';
// Admin pages use two hooks: module-specific + shared admin.common
export default function ContactsPage() {
const t = useTranslations('admin.contacts'); // Module-specific
const tc = useTranslations('admin.common'); // Shared terms
// IMPORTANT: stateConfig must be inside component (after hooks init)
const stateConfig = {
active: { label: tc('statusActive'), className: 'bg-green-100 text-green-800' },
inactive: { label: tc('statusInactive'), className: 'bg-gray-100 text-gray-700' },
};
return (
<div>
<h1>{t('title')}</h1>
<button>{tc('addNew')}</button>
{/* ... */}
</div>
);
}
Composant de changement de langue
// components/language-switcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation'; // NOT next/navigation
import { locales, localeNames } from '@/i18n/routing';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Globe } from 'lucide-react';
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const handleLocaleChange = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Change language">
<Globe className="h-4 w-4" />
<span>{localeNames[locale as keyof typeof localeNames]}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => handleLocaleChange(loc)}
className={loc === locale ? 'font-bold' : ''}
>
{localeNames[loc]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
Flux de travail de traduction
Ajout de nouvelles clés
- Ajoutez d'abord à
en.json(source de vérité) - Exécutez le script de traduction :
node apps/web/scripts/translate-missing.mjs
Le script trouve les clés présentes dans en.json mais manquantes dans les autres fichiers de paramètres régionaux et appelle l'API Google Translate :
// scripts/translate-missing.mjs (simplified)
import { Translate } from '@google-cloud/translate/build/src/v2/index.js';
import { readFileSync, writeFileSync } from 'fs';
const translate = new Translate({ key: process.env.GOOGLE_TRANSLATE_API_KEY });
async function translateMissingKeys(sourceLocale, targetLocale, sourceMessages, targetMessages) {
const missing = findMissingKeys(sourceMessages, targetMessages);
for (const [keyPath, value] of missing) {
const [translated] = await translate.translate(value, targetLocale);
setNestedKey(targetMessages, keyPath, translated);
}
return targetMessages;
}
Validation de la couverture de traduction
# Check for missing keys across all locales
node apps/web/scripts/validate-blog-i18n.mjs
# Fix flat keys accidentally added
node apps/web/scripts/fix-en-flat-keys.mjs
Traduction de contenu de blog
Pour les fichiers de contenu MDX, les titres de première page restent en anglais (les titres sont en i18n JSON), mais le contenu du corps est traduit par paramètres régionaux :
src/content/blog/
en/ # English originals (or root directory)
odoo-erp-guide.mdx
zh/
odoo-erp-guide.mdx # Translated body
ar/
odoo-erp-guide.mdx
# ... 10 locale directories
// src/lib/blog.ts — locale-aware loader
export async function getPost(slug: string, locale: string) {
// Try locale-specific file first
const localePath = path.join(contentDir, locale, `${slug}.mdx`);
const englishPath = path.join(contentDir, `${slug}.mdx`);
const filePath = fs.existsSync(localePath) ? localePath : englishPath;
const raw = fs.readFileSync(filePath, 'utf8');
const { data: frontmatter, content } = matter(raw);
return { frontmatter, content, locale };
}
Questions fréquemment posées
Pourquoi utiliser localePrefix: 'as-needed' au lieu de always ?
Avec as-needed, les URL anglaises n'ont pas de préfixe (/blog/post), tandis que les autres langues obtiennent un préfixe (/zh/blog/post). Il s'agit de la convention standard : l'anglais est la valeur par défaut et les URL sont claires. Avec always, l'anglais obtient également /en/blog/post, ce qui crée des redirections inutiles et divise l'équité de vos liens anglais existants si vous migrez.
Pourquoi les clés en.json doivent-elles être des objets imbriqués et non des chaînes plates séparées par des points ?
La résolution d'espace de noms de next-intl divise la chaîne d'espace de noms par points pour parcourir l'arborescence JSON. Si vos clés sont des chaînes plates séparées par des points comme "admin.contacts.title", next-intl recherche messages.admin.contacts.title comme chemin imbriqué mais trouve que la clé est littéralement la chaîne "admin.contacts.title" au niveau racine, provoquant un échec de recherche. Utilisez toujours des objets imbriqués réels.
Comment gérer la pluralisation dans toutes les langues ?
next-intl utilise le format de message ICU pour la pluralisation. L'anglais a deux formes plurielles (l'un/l'autre), l'arabe en a six (zéro, un, deux, quelques, plusieurs, autre). Utilisez la syntaxe {count, plural, one {# item} other {# items}} ICU dans vos valeurs de traduction et next-intl applique automatiquement les règles de pluriel correctes pour chaque locale.
Puis-je utiliser next-intl avec Pages Router ?
next-intl v4 est conçu principalement pour le routeur d'applications. Pour Pages Router, utilisez next-intl v2 ou v3. Les modèles diffèrent considérablement : Pages Router utilise getStaticProps + NextIntlProvider, tandis que App Router utilise getRequestConfig + NextIntlClientProvider dans la mise en page. Si vous utilisez Pages Router, envisagez de migrer vers App Router avant de mettre en œuvre i18n à grande échelle.
Comment configurer la prise en charge RTL pour l'arabe et l'ourdou ?
Définissez dir="rtl" sur l'élément <html> lorsque les paramètres régionaux sont ar ou ur. Utilisez le préfixe de variante rtl: de Tailwind pour les styles spécifiques à la direction (rtl:mr-4 devient ml-4 dans RTL). Chargez une police compatible – Noto Sans Arabic couvre les scripts arabes et ourdou. Évitez les valeurs CSS left/right codées en dur ; utilisez les propriétés logiques start/end (ms-4, me-4 dans Tailwind v4).
Quelle est la meilleure approche pour traduire rapidement de grandes quantités de contenu ?
Utilisez l'API Google Translate pour la traduction automatique initiale de toutes les clés et du contenu, puis demandez à des locuteurs natifs de réviser le contenu à haute visibilité (page d'accueil, descriptions de produits, copie CTA). Pour le contenu d’un blog, la traduction automatique est acceptable comme point de départ, mais doit être vérifiée pour en vérifier l’exactitude technique. Prévoyez un budget pour l'examen par des locuteurs natifs d'au moins les 3 à 5 principales régions en termes de trafic.
Prochaines étapes
L'internationalisation bien faite est un fossé concurrentiel : votre contenu atteint 11 fois plus de clients potentiels, vos signaux SEO hreflang se consolident plutôt que de diviser l'équité des liens, et les utilisateurs dans leur langue maternelle se convertissent à des taux nettement plus élevés.
ECOSIRE a construit et maintient une implémentation complète de 11 paramètres régionaux couvrant 5 577 fichiers de contenu, 12 543 clés de traduction et la prise en charge de la mise en page RTL. Si vous avez besoin d'internationalisation pour votre application Next.js, explorez nos services d'ingénierie frontend pour savoir comment nous pouvons la mettre en œuvre pour votre base de code.
Rédigé par
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
Articles connexes
Routeur d'application Next.js 16 : modèles de production et pièges
Modèles de routeur d'applications Next.js 16 prêts pour la production : composants de serveur, stratégies de mise en cache, API de métadonnées, limites d'erreur et pièges de performances à éviter.
Étendre le commerce électronique aux marchés internationaux : un guide stratégique complet
Guide complet sur l'expansion internationale du commerce électronique couvrant les études de marché, la localisation, la logistique, les paiements, la conformité juridique et la stratégie marketing.
ERP pour l'expansion internationale : gérer les opérations multi-pays en 2026
Comment utiliser les systèmes ERP pour gérer l'expansion internationale, y compris la comptabilité multidevises, la conformité fiscale, la localisation, la gestion de la chaîne d'approvisionnement et la configuration multi-entreprises Odoo.