Next.js i18n à l'échelle : 30 langues, 91 000 pages, Vercel ISR

L'année dernière, nous avons lancé un projet Next.js qui me rend encore un peu nerveux quand j'y pense. Trente langues. Plus de 91 000 pages générées statiquement. Vercel ISR maintenant tout à jour. Le genre de projet où une mauvaise décision architecturale signifie que vous regardez des compilations de 4 heures, des factures d'hébergement de 800 $/mois, ou -- pire -- un site qui ne fonctionne tout simplement pas en coréen.

C'est l'histoire de comment nous avons bien fait (et les parties où nous ne l'avons pas fait, au début). Si vous construisez une application Next.js internationalisée à grande échelle et vous demandez si ISR peut réellement la gérer en production, cet article est pour vous.

Next.js i18n à l'échelle : 30 langues, 91 000 pages, Vercel ISR

Table des matières

Le problème : Pourquoi 91 000 pages c'est différent

Laissez-moi planter le décor. Le client était une marque de commerce électronique d'entreprise se développant sur 30 marchés. Chaque marché avait besoin de :

  • Pages de produits localisées (~2 800 produits × 30 locales = 84 000 pages)
  • Pages de catégories (~120 catégories × 30 locales = 3 600 pages)
  • Pages marketing pilotées par CMS (~120 × 30 = 3 600 pages)
  • Total : environ 91 200 URLs uniques

Avec getStaticPaths simple et la génération statique complète, la compilation initiale allait prendre quelque part entre 3 et 5 heures. Ce n'est pas une blague. Nous avons fait des points de repère sur les premiers prototypes et regardé le nombre augmenter. Chaque déploiement signifierait des heures de risque d'indisponibilité, et l'équipe de contenu voulait publier des mises à jour plusieurs fois par jour.

SSR n'était pas non plus une option. Les modèles de trafic du client montrait des pics massifs pendant les ventes -- on parle de 50 000 utilisateurs simultanés. Le rendu côté serveur de 91 000 variantes de page possibles sous cette charge nécessiterait un calcul sérieux et introduirait une latence qui tue les taux de conversion.

ISR était la réponse. Mais ISR à cette échelle a son propre ensemble de défis que la documentation Next.js ne vous prépare vraiment pas.

Décisions architecturales que nous avons prises tôt

Avant d'écrire une seule ligne de code i18n, nous avons pris trois décisions architecturales qui nous ont sauvé des mois de douleur plus tard.

Décision 1 : Routage par sous-chemin, pas par domaines

Next.js supporte deux stratégies i18n : le routage par sous-chemin (/fr/products/...) et le routage par domaine (fr.example.com). Nous avons choisi le routage par sous-chemin. Voici pourquoi :

Facteur Routage par sous-chemin Routage par domaine
Complexité DNS/SSL Un seul domaine 30 domaines/sous-domaines à gérer
Déploiement Vercel Un projet Un projet (mais surcharge config domaine)
Équité de lien SEO Consolidée sur un domaine Divisée entre domaines
Efficacité du cache CDN Meilleure (cache edge partagé) Fragmentée
Configuration analytique Plus simple 30 propriétés ou filtrage complexe

Pour la plupart des projets avec moins de 50 locales, le routage par sous-chemin est la bonne option. Le routage par domaine a du sens quand vous avez besoin de TLDs spécifiques aux pays pour des raisons légales ou quand vos marchés ont des architectures de contenu fondamentalement différentes.

Décision 2 : next-intl plutôt que next-i18next

Nous avons évalué les deux bibliothèques en détail. En 2025, next-intl (v4.x) est devenu le choix plus fort pour les projets App Router, bien que nous étions sur Pages Router pour cette compilation. Même sur Pages Router, next-intl nous a donné :

  • Meilleur support TypeScript avec clés de message type-safe
  • Plus petit bundle client (environ 2,1 KB compressé contre ~5 KB pour next-i18next)
  • Support natif du format de message ICU (pluriels, genre, formatage des nombres)
  • Configuration plus simple pour les pages ISR

Décision 3 : Génération statique partielle + ISR

C'était la grosse. Au lieu d'essayer de générer statiquement les 91 000 pages à la compilation, nous avons pré-compilé uniquement les pages à plus haut trafic (environ 8 000) et avons laissé ISR gérer le reste à la demande.

// pages/[locale]/products/[slug].tsx
export async function getStaticPaths() {
  // Pré-générer uniquement les 100 meilleurs produits × 5 meilleures locales
  const topProducts = await getTopProducts(100);
  const primaryLocales = ['en', 'de', 'fr', 'es', 'ja'];
  
  const paths = topProducts.flatMap(product =>
    primaryLocales.map(locale => ({
      params: { slug: product.slug, locale },
    }))
  );

  return {
    paths,
    fallback: 'blocking', // ISR gère tout le reste
  };
}

Cela a ramené notre temps de compilation de 3+ heures à environ 12 minutes. Les 83 000 pages restantes sont générées à la première demande et mises en cache à la limite.

Next.js i18n à l'échelle : 30 langues, 91 000 pages, Vercel ISR - architecture

Configuration de Next.js i18n pour 30 locales

La configuration i18n intégrée de Next.js dans next.config.js gère la détection de locale et le routage. Voici à quoi ressemblait notre configuration (abrégée) :

// next.config.js
const nextConfig = {
  i18n: {
    locales: [
      'en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'cs', 'da',
      'sv', 'fi', 'nb', 'hu', 'ro', 'bg', 'hr', 'sk', 'sl', 'el',
      'tr', 'ja', 'ko', 'zh-CN', 'zh-TW', 'th', 'vi', 'id', 'ms', 'ar'
    ],
    defaultLocale: 'en',
    localeDetection: false, // Nous gérons cela nous-mêmes
  },
};

Quelques choses à noter ici. Nous avons désactivé localeDetection car la détection intégrée (basée sur les en-têtes Accept-Language) posait des problèmes avec la mise en cache ISR. Quand le CDN de Vercel met en cache une page, la locale doit être déterministe depuis l'URL, pas depuis les en-têtes. Laisser Next.js rediriger automatiquement en fonction de la langue du navigateur signifiait des échecs de cache et un comportement incohérent.

Au lieu de cela, nous avons construit un middleware de détection de locale personnalisé qui s'exécute uniquement sur le chemin racine :

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const SUPPORTED_LOCALES = ['en', 'de', 'fr', /* ... */];
const DEFAULT_LOCALE = 'en';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Redirection uniquement sur le chemin racine
  if (pathname === '/') {
    const acceptLanguage = request.headers.get('accept-language') || '';
    const preferred = acceptLanguage.split(',')[0]?.split('-')[0] || DEFAULT_LOCALE;
    const locale = SUPPORTED_LOCALES.includes(preferred) ? preferred : DEFAULT_LOCALE;
    
    return NextResponse.redirect(new URL(`/${locale}`, request.url));
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ['/'],
};

Structure des fichiers de traduction

Avec 30 langues, la gestion des fichiers de traduction devient une vraie préoccupation. Nous avons organisé les traductions par espace de noms :

messages/
├── en/
│   ├── common.json
│   ├── product.json
│   ├── checkout.json
│   └── marketing.json
├── de/
│   ├── common.json
│   ├── product.json
│   └── ...
└── ar/
    └── ...

La charge utile totale de traduction à travers toutes les langues était d'environ 4,2 Mo. Mais parce que nous chargeons les traductions par page en utilisant getStaticProps, chaque page individuelle charge uniquement 15-40 KB de données de traduction pour sa locale et son espace de noms. C'est critique -- vous ne voulez pas expédier toutes les 30 locales au client.

export async function getStaticProps({ locale }: GetStaticPropsContext) {
  return {
    props: {
      messages: {
        ...(await import(`../../messages/${locale}/common.json`)).default,
        ...(await import(`../../messages/${locale}/product.json`)).default,
      },
    },
    revalidate: 300, // ISR : révalider toutes les 5 minutes
  };
}

Support RTL pour l'arabe

L'arabe était la seule langue RTL dans notre ensemble. Nous l'avons géré avec un simple wrapper de mise en page :

const direction = locale === 'ar' ? 'rtl' : 'ltr';

return (
  <html lang={locale} dir={direction}>
    <body className={direction === 'rtl' ? 'font-arabic' : 'font-sans'}>
      {children}
    </body>
  </html>
);

Plus la variante Tailwind rtl: pour les ajustements d'espacement et de mise en page. Cela a fonctionné étonnamment bien -- peut-être 5% de notre CSS avaient besoin de surcharges spécifiques RTL.

La stratégie ISR qui a réellement fonctionné

ISR (Incremental Static Regeneration) est le héros de cette histoire, mais l'utiliser bien à l'échelle nécessite de comprendre comment l'infrastructure de Vercel fonctionne réellement.

Calendrier de révalidation

Nous avons utilisé différentes périodes de révalidation selon le type de contenu :

Type de page Période de révalidation Justification
Pages de produits 300s (5 min) Les prix/stocks changent fréquemment
Pages de catégories 900s (15 min) Les listes de produits se mettent à jour moins souvent
Pages marketing/CMS 3600s (1 heure) Les changements de contenu sont planifiés
Accueil par locale 600s (10 min) Équilibre entre fraîcheur et mise en cache

Révalidation à la demande

Pour les mises à jour critiques (changements de prix, ruptures de stock), nous avons configuré la révalidation à la demande via webhook de notre CMS headless :

// pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { secret, slug, locales } = req.body;
  
  if (secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ message: 'Invalid secret' });
  }

  try {
    const targetLocales = locales || ['en']; // Par défaut l'anglais si non spécifié
    
    const revalidations = targetLocales.map((locale: string) =>
      res.revalidate(`/${locale}/products/${slug}`)
    );
    
    await Promise.all(revalidations);
    
    return res.json({ revalidated: true, paths: targetLocales.length });
  } catch (err) {
    return res.status(500).json({ message: 'Error revalidating' });
  }
}

Un piège : quand vous révalidez un produit qui existe dans 30 locales, vous faites 30 appels de révalidation. Pour une mise à jour en masse de 100 produits, c'est 3 000 demandes de révalidation. Nous avons dû ajouter une limitation de débit et mettre en file d'attente ceux-ci via une fonction serverless pour éviter de dépasser les limites d'API de Vercel.

Le modèle Stale-While-Revalidate

La beauté d'ISR est qu'elle sert du contenu obsolète tout en régénérant en arrière-plan. Pour ce projet, cela signifiait que les utilisateurs obtenaient toujours une réponse rapide (HTML mis en cache depuis la limite de Vercel), même si les données avaient jusqu'à 5 minutes de retard. Pour un site de commerce électronique, c'était un compromis acceptable -- le flux de panier et de paiement a toujours frappé les API en direct pour les données en temps réel de stock/prix.

Pipeline de contenu et intégration CMS headless

Le contenu vivait dans un CMS headless (Contentful, dans ce cas, bien que nous ayons fait des configurations similaires avec Sanity et Storyblok pour d'autres clients -- voir nos services de développement CMS headless pour plus à ce sujet).

Le modèle de localisation de Contentful a bien fonctionné pour 30 locales. Chaque entrée a des valeurs de champs spécifiques à la locale, et leur API supporte les requêtes par locale. Mais il y a une considération de performance : récupérer un produit avec toutes les données de 30 locales est considérablement plus volumineux que récupérer une seule locale.

Nous avons toujours interrogé une seule locale dans getStaticProps :

const product = await contentfulClient.getEntry(productId, {
  locale: mapToContentfulLocale(locale), // 'en-US', 'de-DE', etc.
  include: 2, // Résoudre 2 niveaux d'entrées liées
});

Cela a maintenu les temps de réponse de l'API sous 200 ms même pour les entrées de produits complexes avec plusieurs références.

Gestion des traductions

Pour les traductions d'interface (boutons, étiquettes, messages d'erreur), nous avons utilisé Crowdin intégré avec notre référentiel Git. Le flux de travail :

  1. Les développeurs ajoutent de nouvelles chaînes en anglais à messages/en/*.json
  2. Crowdin se synchronise et informe les traducteurs
  3. Les traductions reviennent sous forme de PR
  4. CI valide la structure JSON et l'exhaustivité
  5. Les traductions manquantes se replient sur l'anglais

La stratégie de repli est critique. Vous ne voulez jamais qu'une page de production affiche des clés de traduction comme product.add_to_cart. Notre chaîne de repli était : locale demandée → famille de langue (par exemple, pt-BRpt) → Anglais.

Résultats de performance et Core Web Vitals

Après le lancement, voici ce que nous avons mesuré sur les 30 locales :

Métrique Cible Réel (P75) Notes
LCP < 2,5s 1,8s Accès au cache ISR
FID < 100ms 45ms JS côté client minimal
CLS < 0,1 0,03 La stratégie de chargement des polices a aidé
TTFB < 800ms 120ms Limite Vercel, pages en cache
TTFB (cache miss) < 2s 1,4s Génération ISR à la première demande
Temps de compilation < 20min 11min 40s Pré-générer uniquement 8 000 pages

Les chiffres TTFB sont les stars ici. 120 ms pour les pages en cache signifie que les utilisateurs à Tokyo, São Paulo et Francfort obtiennent tous des réponses rapides depuis des nœuds edge proches. Le 1,4s pour les échecs de cache est le temps de génération ISR -- acceptable car cela ne se produit qu'une fois par page par période de révalidation.

Chargement des polices pour 30 langues

Un défi de performance spécifique aux sites multilingues : les polices. Vous ne pouvez pas utiliser une seule famille de polices pour 30 langues. Nous avions besoin de :

  • Latin/Cyrillique : Inter (la plupart des langues européennes)
  • Arabe : Noto Sans Arabic
  • CJK : Noto Sans JP/KR/SC/TC
  • Thaï : Noto Sans Thai

L'utilisation de next/font avec chargement de polices par locale a empêché les téléchargements de polices inutiles. Un utilisateur visitant le site japonais télécharge uniquement Noto Sans JP, pas les polices arabes ou thaïes.

Ventilation des coûts sur Vercel

Parlons d'argent, car c'est là que l'ISR à grande échelle devient intéressant. Voici notre ventilation des factures mensuelles Vercel en 2025 :

Poste Coût mensuel Notes
Plan Vercel Pro 20 $/siège × 4 Plan d'équipe de base
Bande passante (8 To/mo) ~320 $ 40 $/To après le premier To
Exécutions de fonction serverless ~180 $ Régénération ISR + routes API
Exécutions Edge Middleware ~45 $ Détection de locale
Écritures ISR ~90 $ Opérations d'écriture en cache
Total ~715 $/mo

Pour un site gérant 2M+ vues de pages/mois sur 30 locales, 715 $ est extrêmement raisonnable. L'alternative -- exécuter SSR sur une infrastructure dédiée -- aurait coûté 2 000-4 000 $/mois pour une performance et une fiabilité équivalentes.

Une chose à regarder : les coûts d'écriture de cache ISR peuvent augmenter si vous déclenchez une révalidation en masse. Nous avons eu un incident où une publication en masse de CMS a déclenché la révalidation de 15 000 pages simultanément. Cet événement unique a coûté environ 40 $ en exécutions de fonction supplémentaires. Nous batch maintenant les appels de révalidation avec un délai de 100 ms entre eux.

Erreurs que nous avons commises et comment nous les avons corrigées

Je mentirais si je disais que cela s'est déroulé sans problème depuis le premier jour. Voici les plus grandes erreurs :

Erreur 1 : Génération de toutes les locales à la compilation

Notre première approche a essayé de pré-générer chaque page dans chaque locale. La compilation a duré 3 heures et 47 minutes. Puis elle a échoué car le délai d'attente de compilation de Vercel (sur Pro) est de 45 minutes. Même après le passage à un serveur de compilation personnalisé, le processus de déploiement était miserable.

Correctif : Génération statique partielle avec fallback: 'blocking'. Compilation uniquement des pages qui importent le plus, laisser ISR gérer la longue traîne.

Erreur 2 : Non configuration correcte de `fallback`

Nous avons initialement utilisé fallback: true au lieu de fallback: 'blocking'. La différence est importante : true sert un état squelette/chargement à la première demande, tandis que blocking attend la génération de la page. Avec true, nous avions des erreurs d'hydratation car nos composants de produit s'attendaient à des données qui n'étaient pas encore là pendant le rendu de repli.

Correctif : Passage à fallback: 'blocking'. Le premier visiteur d'une page non mise en cache attend 1-2 secondes, mais tout le monde après obtient la version en cache instantanément.

Erreur 3 : Les balises hreflang du SEO étaient fausses

C'est facile de se tromper. Google a besoin de balises hreflang pour comprendre la relation entre les pages localisées. Notre implémentation initiale manquait la balise x-default et avait des incohérences entre les balises <link> et le sitemap XML.

// Implémentation hreflang correcte
<Head>
  {locales.map(loc => (
    <link
      key={loc}
      rel="alternate"
      hrefLang={loc}
      href={`https://example.com/${loc}${path}`}
    />
  ))}
  <link rel="alternate" hrefLang="x-default" href={`https://example.com/en${path}`} />
</Head>

Erreur 4 : Génération de sitemap

Avec 91 000 URL, un seul fichier XML de sitemap ne fonctionne pas (la limite de Google est de 50 000 URL par sitemap). Nous avions besoin d'un index de sitemap avec plusieurs sitemaps enfants, divisé par locale :

<!-- sitemap-index.xml -->
<sitemapindex>
  <sitemap><loc>https://example.com/sitemaps/en.xml</loc></sitemap>
  <sitemap><loc>https://example.com/sitemaps/de.xml</loc></sitemap>
  <!-- ... 28 plus -->
</sitemapindex>

Nous avons généré ceux-ci en utilisant next-sitemap avec configuration personnalisée, et ils sont régénérés à chaque compilation.

Quand utiliser cette pile technologique (et quand ne pas le faire)

Cette architecture -- Next.js + i18n + ISR sur Vercel -- est puissante, mais ce n'est pas le bon choix pour tout.

Utilisez cela quand :

  • Vous avez 10+ locales avec des milliers de pages
  • Les mises à jour de contenu sont fréquentes mais pas en temps réel
  • La performance et les Core Web Vitals importent pour le SEO
  • Votre équipe connaît bien React/Next.js

Considérez des alternatives quand :

  • Vous avez moins de 5 locales et moins de 1 000 pages (SSG simple pourrait être plus facile)
  • Le contenu est vraiment en temps réel (commerce électronique de valeurs, scores en direct) -- utilisez SSR ou récupération côté client
  • Vous êtes limité en budget pour l'hébergement -- envisagez Astro pour les sites multilingues purement statiques à une fraction du coût
  • Votre équipe est petite et n'a pas besoin de l'interactivité React -- un générateur de sites statiques avec i18n pourrait être moins à maintenir

Pour les équipes envisageant un projet comme celui-ci, nous avons aidé plusieurs clients d'entreprise à concevoir et construire des applications Next.js à grande échelle. Les décisions architecturales des deux premières semaines déterminent si le projet réussit ou devient un cauchemar de maintenance. Si vous voulez discuter de votre situation spécifique, contactez-nous.

FAQ

Comment fonctionne le routage i18n de Next.js avec ISR ?

Le routage i18n de Next.js ajoute des préfixes de locale aux URL (comme /fr/products/shoes). Combiné avec ISR, chaque combinaison locale + page est mise en cache indépendamment à la limite de Vercel. Donc /en/products/shoes et /fr/products/shoes sont des entrées de cache séparées, chacune avec sa propre minuterie de révalidation. La fonction getStaticProps reçoit la locale dans son contexte, et vous récupérez les traductions et le contenu localisé appropriés là.

Quel est le nombre maximal de pages que Next.js ISR peut gérer sur Vercel ?

Il n'y a pas de limite technique stricte sur le nombre de pages ISR que Vercel peut servir. Nous avons exécuté avec succès 91 000+ pages, et j'ai entendu parler de projets avec 500 000+ pages. Les limites pratiques sont le temps de compilation (pour les pages pré-générées), le débit de révalidation et le coût. Le cache edge de Vercel est conçu pour cette échelle -- c'est essentiellement un CDN avec invalidation intelligente.

ISR affecte-t-il le SEO pour les sites multilingues ?

Non, les pages ISR sont du HTML entièrement rendu lorsqu'elles sont servies depuis le cache, ce que voient les robots des moteurs de recherche. Les principales considérations SEO sont les balises hreflang appropriées, un index de sitemap bien structuré avec des sitemaps par locale, et s'assurer que votre paramètre fallback: 'blocking' empêche les robots de voir des pages incomplètes. Google a confirmé que les pages ISR/en cache sont traitées de la même manière que le HTML statique traditionnel.

Comment gérez-vous les mises à jour de traduction sans redéployer ?

Pour le contenu géré par CMS (descriptions de produits, copy marketing), les traductions se mettent à jour automatiquement via la révalidation ISR -- soit selon la minuterie, soit via les webhooks de révalidation à la demande. Pour les traductions de chaînes d'interface (étiquettes de boutons, messages de validation de formulaire), celles-ci sont regroupées au moment de la compilation, donc elles nécessitent un redéploiement. Nous les gardons séparées intentionnellement : les changements de contenu ne doivent jamais nécessiter de déploiement, mais les changements de chaîne d'interface passent par l'examen du code.

Quelle est la différence de coût entre ISR et SSR pour les sites multilingues sur Vercel ?

SSR exécute une fonction serverless sur chaque demande unique. À 2M vues de pages/mois, c'est 2M invocations de fonction à environ 0,40 $ pour un million après le niveau gratuit -- environ 800 $/mois en coûts de fonction seuls, plus une bande passante significativement plus élevée puisqu'il y a moins de mise en cache. Notre configuration ISR a coûté environ 715 $/mois au total, tandis que SSR équivalent aurait coûté 2 500-3 500 $/mois.

Comment gérez-vous les formats de date, de nombre et de devise différents sur 30 locales ?

Nous utilisons l'API Intl intégrée du navigateur via les utilitaires de formatage de next-intl. Cela gère correctement le formatage des dates (Intl.DateTimeFormat), le formatage des nombres (Intl.NumberFormat) et l'affichage des devises pour chaque locale. Le format de message ICU vous permet d'intégrer ces formateurs directement dans des chaînes de traduction : "price": "From {amount, number, ::currency/EUR}". Cela fonctionne côté serveur pendant la génération ISR et côté client pour les valeurs dynamiques.

Devrais-je utiliser App Router ou Pages Router pour les i18n à grande échelle ?

À partir de Next.js 15 (mi-2025), l'histoire i18n d'App Router s'est considérablement améliorée, et next-intl v4 a un excellent support App Router. Pour les nouveaux projets, je recommanderais App Router. Il offre un meilleur streaming, React Server Components (qui réduisent le JavaScript côté client), et des contrôles de mise en cache plus granulaires. Notre projet a utilisé Pages Router car il a démarré en 2024 quand le support i18n d'App Router était moins stable, mais un projet greenfield aujourd'hui devrait utiliser App Router.

Que se passe-t-il si la révalidation ISR échoue ? Les utilisateurs voient-ils une page d'erreur ?

Non, et c'est l'une des meilleures fonctionnalités d'ISR. Si la révalidation échoue (peut-être que l'API CMS est en panne, ou il y a une erreur de code dans getStaticProps), Vercel continue de servir la dernière version générée avec succès de la page. Les utilisateurs ne voient jamais une erreur -- ils voient juste du contenu légèrement obsolète. La révalidation échouée est enregistrée, et la prochaine tentative de révalidation réessayera. Cela rend ISR incroyablement résiliente par rapport à SSR, où une panne d'API devient immédiatement une panne visible par l'utilisateur.