Tutoriels//10 min de lecture

Next.js App Router i18n : Server Components et patterns RSC pour 2026

Eray Gündoğmuş
Partager

L'App Router a changé notre façon de penser la récupération de données dans Next.js. Il a également changé la manière dont nous devrions aborder l'i18n. La plupart des guides existants traitent encore la traduction comme une préoccupation côté client — un ensemble de fichiers JSON chargés au moment de l'exécution. Cela avait du sens à l'ère du Pages Router. En 2026, avec les React Server Components comme valeur par défaut, nous pouvons faire mieux.

Cet article présente les patterns que nous avons adoptés pour l'i18n natif RSC : comment détecter la locale dans le middleware, comment récupérer les traductions dans les server components sans surcharge côté client, comment hydrater proprement les îlots client, et comment la livraison CDN-first change complètement la donne.


Pourquoi les RSC changent les fondamentaux de l'i18n

Avec le Pages Router, getServerSideProps ou getStaticProps récupéraient les traductions côté serveur, puis les sérialisaient dans le bundle de la page. Le client réhydratait tout, y compris les dictionnaires de traduction qui ne changeaient jamais par utilisateur.

Les Server Components éliminent ce pattern. Il n'y a pas d'étape d'hydratation pour la sortie RSC — le HTML est final. Cela signifie que nous pouvons récupérer les traductions dans un server component, afficher les chaînes traduites directement en HTML, et n'envoyer aucun JavaScript lié aux traductions au client. Pas de bundle de dictionnaire, pas de recherche à l'exécution, pas de désaccord d'hydratation.

Le bémol : les composants client qui ont besoin de traductions (formulaires interactifs, interfaces dynamiques) nécessitent toujours un accès côté client. Le pattern devient clair dès lors qu'on sépare explicitement les deux préoccupations.


Middleware : détection de la locale avant que la requête n'atteigne votre application

Le routage par locale se fait dans le middleware, car il doit s'exécuter à la périphérie, avant le rendu de tout composant.

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

const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es'] as const
type Locale = typeof SUPPORTED_LOCALES[number]
const DEFAULT_LOCALE: Locale = 'en'

function detectLocale(request: NextRequest): Locale {
  // 1. Check URL path prefix
  const pathname = request.nextUrl.pathname
  const pathLocale = pathname.split('/')[1] as Locale
  if (SUPPORTED_LOCALES.includes(pathLocale)) {
    return pathLocale
  }

  // 2. Check Accept-Language header
  const acceptLanguage = request.headers.get('accept-language')
  if (acceptLanguage) {
    const preferred = acceptLanguage
      .split(',')
      .map(lang => lang.split(';')[0].trim().slice(0, 2) as Locale)
      .find(lang => SUPPORTED_LOCALES.includes(lang))
    if (preferred) return preferred
  }

  return DEFAULT_LOCALE
}

export function middleware(request: NextRequest) {
  const locale = detectLocale(request)
  const pathname = request.nextUrl.pathname

  // Redirect root to locale-prefixed path
  if (!SUPPORTED_LOCALES.some(l => pathname.startsWith(`/${l}`))) {
    return NextResponse.redirect(
      new URL(`/${locale}${pathname}`, request.url)
    )
  }

  // Inject locale into headers for server components
  const response = NextResponse.next()
  response.headers.set('x-locale', locale)
  return response
}

export const config = {
  matcher: ['/((?!api|_next|_static|favicon.ico).*)'],
}

L'en-tête x-locale est le pont entre le middleware et les server components. Nous le définissons sur chaque requête afin que n'importe quel server component puisse le lire sans prop drilling.


Structure de l'App Router : la locale comme segment de layout

L'approche conventionnelle avec l'App Router consiste à regrouper les routes spécifiques à une locale sous un segment [locale] :

app/
  [locale]/
    layout.tsx        ← définit le contexte de locale, récupère les traductions de base
    page.tsx          ← page d'accueil
    blog/
      [slug]/
        page.tsx
    dashboard/
      page.tsx
  api/
    ...

Le fichier [locale]/layout.tsx est l'endroit où nous initialisons les traductions pour l'ensemble du sous-arbre :

// app/[locale]/layout.tsx
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { TranslationProvider } from '@/components/translation-provider'
import { fetchTranslations } from '@/lib/translations'

const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es']

interface LayoutProps {
  children: React.ReactNode
  params: { locale: string }
}

export default async function LocaleLayout({ children, params }: LayoutProps) {
  const { locale } = await params

  if (!SUPPORTED_LOCALES.includes(locale)) {
    notFound()
  }

  // Fetch base translations for layout-level strings
  const baseTranslations = await fetchTranslations(locale, 'common')

  return (
    <html lang={locale}>
      <body>
        <TranslationProvider locale={locale} messages={baseTranslations}>
          {children}
        </TranslationProvider>
      </body>
    </html>
  )
}

Server Components : récupérer les traductions à la périphérie

Voici le changement clé : plutôt que de bundler les fichiers de locale avec votre application, nous récupérons les traductions depuis un CDN au moment de la requête. La fonction ressemble à n'importe quelle autre récupération de données asynchrone :

// lib/translations.ts
import { cache } from 'react'

type Messages = Record<string, string>

// React cache() deduplicates fetches within a single render pass
export const fetchTranslations = cache(async (
  locale: string,
  namespace: string
): Promise<Messages> => {
  const url = `https://cdn.better-i18n.com/v1/${locale}/${namespace}.json`
  
  const res = await fetch(url, {
    next: {
      revalidate: 3600, // CDN-delivered, revalidate hourly
      tags: [`translations-${locale}-${namespace}`],
    },
  })

  if (!res.ok) {
    // Fall back to English rather than breaking the page
    if (locale !== 'en') {
      return fetchTranslations('en', namespace)
    }
    throw new Error(`Failed to fetch translations: ${res.status}`)
  }

  return res.json()
})

Le wrapper cache() de React signifie que plusieurs server components sur la même page demandant le même namespace ne déclencheront qu'une seule requête réseau par passe de rendu. Combiné avec le cache de récupération de Next.js, la réponse CDN est généralement servie depuis le cache de périphérie — latence infra-milliseconde pour les recherches de traductions.

Un server component utilisant ce pattern :

// app/[locale]/blog/[slug]/page.tsx
import { fetchTranslations } from '@/lib/translations'
import { getPost } from '@/lib/posts'

interface PageProps {
  params: { locale: string; slug: string }
}

export default async function BlogPost({ params }: PageProps) {
  const { locale, slug } = await params

  // Both fetches happen in parallel
  const [t, post] = await Promise.all([
    fetchTranslations(locale, 'blog'),
    getPost(slug),
  ])

  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      <p className="meta">
        {t['blog.readTime'].replace('{n}', String(post.readTimeMinutes))}
      </p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <footer>
        <a href={`/${locale}/blog`}>{t['blog.backToList']}</a>
      </footer>
    </article>
  )
}

Aucun JavaScript côté client. Aucune hydratation. La recherche de clé de traduction s'effectue sur le serveur et le résultat est du HTML sérialisé.


Clés de traduction typées

Les recherches de clés sous forme de chaînes (t['blog.readTime']) échouent silencieusement quand les clés changent. Nous générons un type à partir du fichier de messages de la locale de base :

// types/translations.ts (generated, do not edit manually)
// Generated from: en/blog.json

export type BlogNamespaceKey =
  | 'blog.readTime'
  | 'blog.backToList'
  | 'blog.publishedOn'
  | 'blog.updatedOn'
  | 'blog.authorBy'
  | 'blog.sharePost'
  | 'blog.relatedPosts'

// Typed accessor
export function t(messages: Record<string, string>, key: BlogNamespaceKey): string {
  const value = messages[key]
  if (value === undefined) {
    // In development, surface the missing key
    if (process.env.NODE_ENV === 'development') {
      console.warn(`Missing translation key: ${key}`)
    }
    return key
  }
  return value
}

L'étape de génération s'exécute dans le pipeline CI à chaque modification des fichiers de traduction. TypeScript détecte ensuite les clés manquantes ou renommées à la compilation, pas à l'exécution en production.

C'est un domaine où une plateforme comme Better i18n apporte un vrai avantage — la génération de types est automatisée, et la sortie de types se met à jour dans l'éditeur dès que vous envoyez de nouvelles clés via le tableau de bord.


Hydratation des composants client : les îlots de traduction

Tout ne peut pas être un server component. Les formulaires, modales et interfaces interactives ont besoin d'un accès côté client aux traductions pour les chaînes dynamiques — messages d'erreur, retours de validation, libellés d'état asynchrone.

Le pattern est un provider de contexte qui sérialise le sous-ensemble de messages pertinent :

// components/translation-provider.tsx
'use client'

import { createContext, useContext } from 'react'

type Messages = Record<string, string>

const TranslationContext = createContext<{
  messages: Messages
  locale: string
} | null>(null)

export function TranslationProvider({
  children,
  messages,
  locale,
}: {
  children: React.ReactNode
  messages: Messages
  locale: string
}) {
  return (
    <TranslationContext.Provider value={{ messages, locale }}>
      {children}
    </TranslationContext.Provider>
  )
}

export function useTranslations(namespace?: string) {
  const ctx = useContext(TranslationContext)
  if (!ctx) throw new Error('useTranslations must be used inside TranslationProvider')

  return {
    t: (key: string) => {
      const fullKey = namespace ? `${namespace}.${key}` : key
      return ctx.messages[fullKey] ?? fullKey
    },
    locale: ctx.locale,
  }
}

Le server component du layout récupère les messages, puis les transmet au provider. Les composants client appellent useTranslations() sans déclencher de requêtes supplémentaires :

// components/contact-form.tsx
'use client'

import { useTranslations } from '@/components/translation-provider'
import { useState } from 'react'

export function ContactForm() {
  const { t } = useTranslations('forms')
  const [error, setError] = useState<string | null>(null)

  async function handleSubmit(formData: FormData) {
    const result = await submitContact(formData)
    if (!result.ok) {
      setError(t('contact.submitError'))
    }
  }

  return (
    <form action={handleSubmit}>
      <label>{t('contact.nameLabel')}</label>
      <input name="name" required />
      <label>{t('contact.emailLabel')}</label>
      <input name="email" type="email" required />
      <button type="submit">{t('contact.submitButton')}</button>
      {error && <p role="alert">{error}</p>}
    </form>
  )
}

La contrainte clé : ne transmettez que le ou les namespaces dont le composant client a réellement besoin. Sérialiser l'intégralité du dictionnaire de traductions dans le bundle client annule l'intérêt de la démarche.


Routes statiques : generateStaticParams

Pour les pages générées statiquement (pages marketing, articles de blog, documentation), nous utilisons generateStaticParams pour pré-rendre chaque locale au moment du build :

// app/[locale]/page.tsx

export async function generateStaticParams() {
  const locales = ['en', 'de', 'fr', 'es']
  return locales.map(locale => ({ locale }))
}

export async function generateMetadata({ params }: { params: { locale: string } }) {
  const { locale } = await params
  const t = await fetchTranslations(locale, 'home')

  return {
    title: t['home.meta.title'],
    description: t['home.meta.description'],
    alternates: {
      languages: {
        'en': '/en',
        'de': '/de',
        'fr': '/fr',
        'es': '/es',
      },
    },
  }
}

Avec des traductions livrées par CDN, le build récupère les fichiers de messages depuis le CDN au moment du build et les intègre dans le HTML statique. À l'exécution, le CDN sert le fichier statique — le temps de recherche de traduction est effectivement nul.


Comparaison avec next-intl et next-i18next

next-intl est la bibliothèque la plus mature compatible avec l'App Router. Elle gère une grande partie du boilerplate de middleware et de contexte décrit ci-dessus. La contrepartie est qu'elle suppose toujours que les fichiers de locale résident dans votre dépôt (/messages/en.json, /messages/de.json). Chaque nouvelle traduction nécessite un déploiement de code.

next-i18next a été conçu pour le Pages Router. Dans les projets App Router, c'est au mieux une couche de compatibilité. Nous l'éviterions pour les nouveaux projets.

L'approche CDN-first (décrite ci-dessus) découple le contenu de traduction des déploiements de code. Mettez à jour une traduction française, et elle est en ligne sur le CDN en quelques secondes — pas de redéploiement, pas de PR, pas d'attente de la CI. L'équipe de traduction opère indépendamment de l'équipe d'ingénierie.

L'argument de la simplicité des approches basées sur des fichiers (« c'est plus simple ») s'effondre rapidement dès lors qu'on gère 20 locales et une équipe de localisation dédiée. Fusionner des PR de traduction est du bruit, pas de la valeur.


Ce que nous ferions différemment en 2026

  • Traductions en streaming : <Suspense> autour des composants lourds en traductions pendant que la récupération CDN se résout — utile pour les cas limites où les caches CDN froids provoquent de la latence.
  • Granularité des namespaces par composant : plutôt que des récupérations au niveau du layout, récupérer uniquement les namespaces dont chaque composant a besoin. Réduit la taille du payload sur les pages aux besoins de traduction limités.
  • Règles de pluriel et de formatage : utiliser Intl.PluralRules et Intl.NumberFormat directement plutôt que de s'appuyer sur des abstractions de bibliothèque. Les API du navigateur sont désormais fiables dans tous les environnements supportés.

Pour commencer

Les patterns ci-dessus fonctionnent avec n'importe quelle source de traductions livrée par CDN. Si vous souhaitez éviter la configuration de l'infrastructure et obtenir des clés typées, une livraison CDN automatisée et un éditeur adapté aux traducteurs prêt à l'emploi, l'intégration Next.js de Better i18n couvre cette stack — helpers middleware, génération de clés typées, et un tableau de bord que votre équipe de traduction peut utiliser sans toucher au code.

Consultez la documentation développeur pour la référence API complète et les guides d'intégration SDK.

Comments

Loading comments...