Cours Next.js · Module 9: Intégrations et fonctions avancées
Internationalisation avec next-intl
Dans cette leçon9
La boutique de la Métropole Quantum 2150 accueille des habitants de Varsovie, de New York et de Berlin. Si les textes sont écrits en dur dans les composants, chaque nouvelle langue oblige à copier toute l'application, et les dates et les prix s'affichent quand même dans le mauvais format. Neo-Bot, le traducteur de la Métropole, règle le problème avec la bibliothèque next-intl - l'outil d'internationalisation (i18n) le plus populaire pour le Next.js App Router.
Pourquoi next-intl ?
Next.js ne fournit pas de bibliothèque i18n intégrée pour l'App Router, seulement des patterns de routage. next-intl comble ce manque en offrant :
- Un support complet des Server Components
- Des traductions type-safe avec TypeScript
- Le formatage des dates, des nombres et des devises
- La gestion de la pluralisation
- L'ICU Message Format
- Un routage basé sur la locale
La mise en place suit toujours cet ordre : tu définis les langues prises en charge, tu crées les fichiers de traduction, tu configures un middleware qui détecte la langue, et seulement ensuite tu affiches les traductions dans les composants.
Installation et configuration
Étape 1 : Installation
La bibliothèque est un seul paquet qui fonctionne aussi bien dans les composants serveur que dans les composants client :
1npm install next-intlRien ne change encore après l'installation, car next-intl doit être configuré dans plusieurs fichiers.
Étape 2 : Structure des fichiers de traduction
On garde les traductions dans le dossier messages, un fichier JSON par langue (locale) :
1messages/
2├── pl.json
3├── en.json
4└── de.jsonLes clés sont regroupées en espaces de noms, comme common ou products. Voici le fichier polonais :
1// messages/pl.json
2{
3 "common": {
4 "welcome": "Witaj w Metropolii Quantum!",
5 "login": "Zaloguj się",
6 "logout": "Wyloguj się",
7 "loading": "Ładowanie..."
8 },
9 "home": {
10 "title": "Strona główna",
11 "description": "Odkryj przyszłość technologii",
12 "cta": "Rozpocznij przygodę"
13 },
14 "products": {
15 "title": "Produkty",
16 "price": "Cena: {price, number, ::currency/PLN}",
17 "inStock": "{count, plural, =0 {Brak w magazynie} one {# sztuka} few {# sztuki} many {# sztuk} other {# sztuk}}",
18 "addToCart": "Dodaj do koszyka"
19 }
20}Les valeurs entre accolades sont en ICU Message Format. {price, number, ::currency/PLN} formate un nombre comme une devise, et plural choisit une forme selon le nombre. Le polonais a besoin des catégories one, few et many, car on dit "1 sztuka", "3 sztuki", "5 sztuk".
Le fichier anglais a les mêmes clés mais une pluralisation plus simple, car l'anglais ne connaît que one et other :
1// messages/en.json
2{
3 "common": {
4 "welcome": "Welcome to Quantum Metropolis!",
5 "login": "Log in",
6 "logout": "Log out",
7 "loading": "Loading..."
8 },
9 "home": {
10 "title": "Home",
11 "description": "Discover the future of technology",
12 "cta": "Start your journey"
13 },
14 "products": {
15 "title": "Products",
16 "price": "Price: {price, number, ::currency/USD}",
17 "inStock": "{count, plural, =0 {Out of stock} one {# item} other {# items}}",
18 "addToCart": "Add to cart"
19 }
20}Les clés doivent être identiques dans tous les fichiers, seules les valeurs changent.
Étape 3 : Configurer next-intl
D'abord, une source de vérité unique pour les langues. as const transforme le tableau en tuple de littéraux, dont on dérive le type Locale :
1// i18n/config.ts
2export const locales = ['pl', 'en', 'de'] as const;
3export const defaultLocale = 'pl' as const;
4
5export type Locale = (typeof locales)[number];TypeScript sait maintenant que Locale vaut exactement 'pl' | 'en' | 'de'.
Le fichier i18n/request.ts indique à la bibliothèque quelles traductions charger pour une requête donnée. La fonction getRequestConfig reçoit requestLocale, une promesse contenant la langue lue dans l'URL :
1// i18n/request.ts
2import { getRequestConfig } from 'next-intl/server';
3import { locales, defaultLocale } from './config';
4
5export default getRequestConfig(async ({ requestLocale }) => {
6 // Validation de la locale
7 let locale = await requestLocale;
8 if (!locale || !locales.includes(locale as any)) {
9 locale = defaultLocale;
10 }
11
12 return {
13 locale,
14 messages: (await import(`../messages/${locale}.json`)).default,
15 timeZone: 'Europe/Warsaw',
16 now: new Date(),
17 };
18});On remplace une langue inconnue par la langue par défaut, et la fonction renvoie locale avec les messages, le fuseau horaire et la date actuelle. requestLocale fonctionne dans next-intl 4, même si la documentation des dernières versions le présente comme l'ancienne approche et lit la langue via next/root-params dans Next.js 16.3+.
Étape 4 : Middleware
Le middleware détecte la langue à partir de l'URL ou des en-têtes du navigateur et redirige vers le bon chemin. Dans Next.js 16, ce fichier s'appelle proxy.ts :
1// middleware.ts (Next.js 16 : proxy.ts)
2import createMiddleware from 'next-intl/middleware';
3import { locales, defaultLocale } from './i18n/config';
4
5export default createMiddleware({
6 locales,
7 defaultLocale,
8 localePrefix: 'as-needed', // ou 'always' ou 'never'
9});
10
11export const config = {
12 matcher: [
13 // Tous les chemins sauf API, _next, fichiers statiques
14 '/((?!api|_next|_vercel|.*\..*).*)',
15 ],
16};localePrefix: 'as-needed' masque le préfixe pour la langue par défaut, donc la page polonaise est à l'adresse /products et la page anglaise à /en/products. Le matcher ignore l'API, les fichiers de Next.js et les fichiers statiques.
Étape 5 : Structure des dossiers avec [locale]
On déplace toutes les pages dans le segment dynamique [locale], ce qui intègre la langue à l'URL :
1app/
2├── [locale]/
3│ ├── layout.tsx
4│ ├── page.tsx
5│ ├── products/
6│ │ ├── page.tsx
7│ │ └── [id]/
8│ │ └── page.tsx
9│ └── about/
10│ └── page.tsx
11├── api/
12└── globals.cssLe dossier api reste en dehors du segment, car les endpoints n'ont pas besoin de versions linguistiques.
Étape 6 : Root Layout avec la locale
Le layout de [locale] devient le layout racine de l'application. generateStaticParams génère des versions statiques pour toutes les langues, et params est une promesse depuis Next.js 15 (dans Next.js 16, on ne peut plus le lire de façon synchrone) :
1// app/[locale]/layout.tsx
2import { NextIntlClientProvider } from 'next-intl';
3import { getMessages } from 'next-intl/server';
4import { locales } from '@/i18n/config';
5import { notFound } from 'next/navigation';
6
7export function generateStaticParams() {
8 return locales.map((locale) => ({ locale }));
9}
10
11export default async function LocaleLayout({
12 children,
13 params,
14}: {
15 children: React.ReactNode;
16 params: Promise<{ locale: string }>;
17}) {
18 const { locale } = await params;
19
20 // Validation de la locale
21 if (!locales.includes(locale as any)) {
22 notFound();
23 }
24
25 const messages = await getMessages();
26
27 return (
28 <html lang={locale}>
29 <body>
30 <NextIntlClientProvider messages={messages}>
31 {children}
32 </NextIntlClientProvider>
33 </body>
34 </html>
35 );
36}Une langue non prise en charge aboutit à une page 404. NextIntlClientProvider met les traductions à disposition des composants client, et dans next-intl 4 il hérite automatiquement des messages, donc la prop messages est optionnelle.
Utiliser les traductions
Dans les Server Components
Dans les composants serveur, tu utilises la fonction asynchrone getTranslations avec le nom d'un espace de noms :
1// app/[locale]/page.tsx
2import { getTranslations } from 'next-intl/server';
3
4export default async function HomePage() {
5 const t = await getTranslations('home');
6 const tCommon = await getTranslations('common');
7
8 return (
9 <main>
10 <h1>{t('title')}</h1>
11 <p>{t('description')}</p>
12 <button>{t('cta')}</button>
13 <p>{tCommon('welcome')}</p>
14 </main>
15 );
16}
17
18// Générer les métadonnées avec les traductions
19export async function generateMetadata({ params }: Props) {
20 const { locale } = await params;
21 const t = await getTranslations({ locale, namespace: 'home' });
22
23 return {
24 title: t('title'),
25 description: t('description'),
26 };
27}La fonction t renvoie le texte d'une clé dans l'espace choisi. generateMetadata l'utilise avec une locale explicite, donc le titre de la page est traduit lui aussi.
Dans les Client Components
Dans les composants client, le hook useTranslations offre la même interface :
1// components/ProductCard.tsx
2'use client';
3
4import { useTranslations } from 'next-intl';
5
6interface ProductCardProps {
7 product: {
8 id: string;
9 name: string;
10 price: number;
11 stock: number;
12 };
13}
14
15export function ProductCard({ product }: ProductCardProps) {
16 const t = useTranslations('products');
17
18 return (
19 <div className="product-card">
20 <h3>{product.name}</h3>
21 <p>{t('price', { price: product.price })}</p>
22 <p>{t('inStock', { count: product.stock })}</p>
23 <button>{t('addToCart')}</button>
24 </div>
25 );
26}Le second argument de t transmet des variables au message, donc { count: product.stock } choisira la bonne forme du pluriel. Le composant ne connaît pas la langue, c'est le provider qui la connaît.
Formatage
Dates et heures
Le hook useFormatter formate les dates selon les règles de la langue courante, en s'appuyant sur l'API Intl du navigateur :
1import { useFormatter } from 'next-intl';
2
3function EventDate({ date }: { date: Date }) {
4 const format = useFormatter();
5
6 return (
7 <div>
8 {/* Date complète */}
9 <p>{format.dateTime(date, { dateStyle: 'full' })}</p>
10
11 {/* Date seule */}
12 <p>{format.dateTime(date, {
13 year: 'numeric',
14 month: 'long',
15 day: 'numeric'
16 })}</p>
17
18 {/* Temps relatif */}
19 <p>{format.relativeTime(date)}</p>
20
21 {/* Plage de dates */}
22 <p>{format.dateTimeRange(startDate, endDate, {
23 dateStyle: 'medium'
24 })}</p>
25 </div>
26 );
27}Le même code produit "sobota, 26 września 2026" en polonais et "Saturday, September 26, 2026" en anglais.
Nombres et devises
Les mêmes règles s'appliquent aux nombres, aux pourcentages, aux unités et aux listes. Le séparateur des milliers, le symbole de la devise et la conjonction de la liste dépendent de la langue :
1import { useFormatter } from 'next-intl';
2
3function PriceDisplay({ amount }: { amount: number }) {
4 const format = useFormatter();
5
6 return (
7 <div>
8 {/* Devise */}
9 <p>{format.number(amount, { style: 'currency', currency: 'PLN' })}</p>
10
11 {/* Pourcentage */}
12 <p>{format.number(0.25, { style: 'percent' })}</p>
13
14 {/* Unités */}
15 <p>{format.number(1500, {
16 style: 'unit',
17 unit: 'kilometer',
18 unitDisplay: 'long'
19 })}</p>
20
21 {/* Listes */}
22 <p>{format.list(['React', 'Next.js', 'TypeScript'], { type: 'conjunction' })}</p>
23 </div>
24 );
25}format.list insère "i" en polonais et "and" en anglais, sans aucune condition dans le code.
Sélecteur de langue
La navigation qui tient compte de la langue vient de la fonction createNavigation. Tu la crées une seule fois, dans un fichier séparé :
1// i18n/navigation.ts
2import { createNavigation } from 'next-intl/navigation';
3import { locales, defaultLocale } from './config';
4
5export const { Link, redirect, usePathname, useRouter } = createNavigation({
6 locales,
7 defaultLocale,
8});Ces versions de Link, useRouter et usePathname connaissent la liste des langues et ajoutent elles-mêmes le préfixe à l'URL. Le sélecteur les utilise ainsi :
1// components/LocaleSwitcher.tsx
2'use client';
3
4import { useLocale } from 'next-intl';
5import { usePathname, useRouter } from '@/i18n/navigation';
6import { locales } from '@/i18n/config';
7
8const localeNames: Record<string, string> = {
9 pl: 'Polski',
10 en: 'English',
11 de: 'Deutsch',
12};
13
14export function LocaleSwitcher() {
15 const locale = useLocale();
16 const router = useRouter();
17 const pathname = usePathname();
18
19 const handleChange = (newLocale: string) => {
20 router.replace(pathname, { locale: newLocale });
21 };
22
23 return (
24 <select
25 value={locale}
26 onChange={(e) => handleChange(e.target.value)}
27 className="bg-gray-800 text-white px-3 py-2 rounded"
28 >
29 {locales.map((loc) => (
30 <option key={loc} value={loc}>
31 {localeNames[loc]}
32 </option>
33 ))}
34 </select>
35 );
36}router.replace(pathname, { locale: newLocale }) reste sur la même page et ne change que la langue. L'ancien import depuis next-intl/client n'existe plus dans les versions actuelles de la bibliothèque.
Liens qui conservent la locale
Le Link du même fichier ajoute automatiquement le préfixe de la langue courante :
1import { Link } from '@/i18n/navigation';
2
3function Navigation() {
4 return (
5 <nav>
6 {/* Link ajoute automatiquement le préfixe de locale */}
7 <Link href="/">Home</Link>
8 <Link href="/products">Products</Link>
9 <Link href="/about">About</Link>
10
11 {/* Link vers une locale spécifique */}
12 <Link href="/about" locale="en">About (EN)</Link>
13 </nav>
14 );
15}La prop locale permet de pointer vers une autre langue, par exemple dans un pied de page qui liste les versions linguistiques.
Traductions type-safe
On peut dériver le type des messages du fichier JSON polonais et l'enregistrer dans next-intl en étendant l'interface AppConfig :
1// types/messages.ts
2import pl from '../messages/pl.json';
3
4// Typage automatique à partir du fichier JSON
5type Messages = typeof pl;
6
7declare module 'next-intl' {
8 interface AppConfig {
9 Messages: Messages;
10 }
11}TypeScript va maintenant suggérer les clés disponibles et vérifier aussi que tu passes les paramètres requis du message :
1const t = useTranslations('products');
2t('title'); // OK
3t('price', { price: 100 }); // OK avec paramètre
4t('nonExistent'); // Erreur TypeScript !Une faute de frappe dans une clé bloque le build au lieu d'afficher à l'utilisateur le texte brut products.nonExistent.
Exemple pratique - E-commerce
La page des produits combine la traduction côté serveur avec le composant client ProductCard de la section précédente :
1// app/[locale]/products/page.tsx
2import { getTranslations } from 'next-intl/server';
3import { ProductCard } from '@/components/ProductCard';
4
5export default async function ProductsPage() {
6 const t = await getTranslations('products');
7 const products = await getProducts();
8
9 return (
10 <div className="container mx-auto py-8">
11 <h1 className="text-3xl font-bold mb-8">{t('title')}</h1>
12
13 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
14 {products.map((product) => (
15 <ProductCard key={product.id} product={product} />
16 ))}
17 </div>
18 </div>
19 );
20}Le serveur traduit le titre, et les cartes traduisent elles-mêmes leurs libellés. Le fichier polonais étendu ajoute des filtres, le tri et un compteur de résultats :
1// messages/pl.json étendu
2{
3 "products": {
4 "title": "Nasze produkty",
5 "filters": {
6 "all": "Wszystkie",
7 "inStock": "Dostępne",
8 "onSale": "W promocji"
9 },
10 "sort": {
11 "label": "Sortuj",
12 "priceAsc": "Cena: od najniższej",
13 "priceDesc": "Cena: od najwyższej",
14 "newest": "Najnowsze"
15 },
16 "empty": "Nie znaleziono produktów",
17 "results": "{count, plural, =0 {Brak wyników} one {# produkt} few {# produkty} many {# produktów} other {# produktów}}"
18 }
19}Tu lis les objets imbriqués, comme filters ou sort, avec une clé à points, par exemple t('filters.all').
Résumé
next-intl offre une solution i18n complète pour Next.js :
- Server Components - support complet avec
getTranslations - Client Components - les hooks
useTranslationsetuseFormatter - Type Safety - TypeScript suggère les clés disponibles
- Formatage - dates, nombres, devises, listes
- Routage - préfixes de locale automatiques dans l'URL
- Pluralisation - accord correct pour chaque langue
Mon conseil : garde les textes dans des fichiers JSON dès le premier jour, même si aujourd'hui tu ne gères qu'une seule langue. Dans la leçon suivante, tu emballeras l'application dans un conteneur Docker.
Retiens : dans la Métropole Quantum, un composant ne connaît que les clés, et next-intl choisit pour lui la langue, les formes du pluriel et le format des dates.
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Crée une route API pour la Checkout Session
4
5export default function CheckoutSessionAPI() {
6 const [requestBody, setRequestBody] = useState(JSON.stringify({
7 priceId: 'price_1234',
8 quantity: 1,
9 successUrl: '/success',
10 cancelUrl: '/cancel',
11 }, null, 2));
12 const [response, setResponse] = useState<any>(null);
13 const [isLoading, setIsLoading] = useState(false);
14
15 // TODO : Implémente simulateCreateSession
16 // Parse requestBody, valide, retourne l'URL de session
17
18 return (
19 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
20 <h1 style={{ color: '#64ffda' }}>Checkout Session API</h1>
21 <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
22 <div>
23 <h3>Request Body</h3>
24 <textarea value={requestBody} onChange={e => setRequestBody(e.target.value)}
25 style={{ width: '100%', height: 200, background: '#0d1117', color: '#e0e0e0', border: '1px solid #333',
26 borderRadius: 8, padding: 10, fontFamily: 'monospace', fontSize: 12 }} />
27 {/* TODO : Bouton « Envoyer la requête » */}
28 </div>
29 <div>
30 <h3>Response</h3>
31 {/* TODO : Affiche le JSON de réponse */}
32 </div>
33 </div>
34 </div>
35 );
36}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. next-intl dans Next.js sert à :
2. Que fournit la bibliothèque next-intl dans les applications Next.js ?
Tâches pratiques dans le jeu
- Éditeur de code
Construis une interface avec support multilingue (i18n)
- Tri vertical
Remets dans l'ordre les étapes d'implémentation de l'internationalisation (i18n) dans Next.js