Aller au contenu
19 mai 20266 min readTutorials

Internationaliser React Router v7 avec remix-i18next

En mai 2024, l'équipe Remix a annoncé que Remix en tant que framework autonome est intégré à React Router v7, et que l'expérience développeur typique de Remix devient le «mode Framework» de React Router. La bibliothèque i18n utilisée par la plupart des apps Remix, remix-i18next, a suivi le mouvement : à partir de la version 7.x, elle cible le mode Framework de React Router v7 (et la branche 6.x continue de prendre en charge Remix v2).

Cet article guide à travers la nouvelle forme : comment localiser une app React Router v7 avec i18next, remix-i18next 7.x et la plateforme de gestion des traductions Locize, incluant le nouveau pattern middleware de React Router, le rendu côté serveur avec traductions empaquetées et le lazy loading côté client basé sur CDN.

Si vous êtes encore sur Remix v2, suivez plutôt l'article Remix v2 et restez sur remix-i18next 6.x.

Si vous utilisez React Router v7 en mode SPA (sans SSR), utilisez directement react-i18next. Cet article se concentre sur le mode Framework + SSR.

TL;DR

  • Utilisez le middleware de remix-i18next 7.x (createI18nextMiddleware) pour détecter la locale par requête et initialiser une instance i18next par requête.
  • Empaquetez les traductions côté serveur via des imports JSON statiques (pas d'appels CDN à l'exécution, compatible serverless).
  • Utilisez i18next-locize-backend sur le client pour faire du lazy load des traductions fraîches depuis le CDN Locize. Les mises à jour publiées apparaissent sans redéploiement.
  • Envoyez automatiquement les clés manquantes à Locize avec saveMissing.
  • Modifiez en contexte avec le plugin locize (ajoutez ?incontext=true à une URL).
  • Exemple complet : github.com/locize/locize-react-router-example

Ce qui a changé par rapport à Remix v2

Trois parties de l'intégration ont substantiellement changé entre remix-i18next 6.x (Remix v2) et 7.x (React Router v7) :

DomaineRemix v2 / remix-i18next 6React Router v7 / remix-i18next 7
Détection de la localeUne classe RemixI18Next instanciée une seule fois au chargement du module, appelée explicitement depuis les loadersUn middleware de route (createI18nextMiddleware) déclaré dans root.tsx, qui s'exécute par requête et initialise le contexte
Accès aux traductions dans le loaderremixI18n.getLocale(request) / remixI18n.getFixedT(request, ns)getLocale(context) / getInstance(context) depuis le tuple renvoyé par le middleware
Système de buildCompilateur classique de Remix ou plugin Vite de RemixPlugin Vite de React Router (@react-router/dev/vite), Vite est la seule voie possible

Les plugins de traduction (i18next-locize-backend, locize, locize-lastused, i18next-browser-languagedetector) restent inchangés.

C'est parti

Prérequis

Assurez-vous d'avoir Node.js 20+ et une version récente de npm/pnpm/yarn. Vous devriez être familier avec React et avoir lu au moins une fois la documentation du mode Framework de React Router v7.

Layout du projet

Vous pouvez générer une nouvelle app React Router v7 avec le starter officiel :

npx create-react-router@latest my-app
cd my-app

Pour le reste de cet article, les parties pertinentes du layout de l'app sont :

app/
├── root.tsx                — déclare le middleware i18next
├── entry.client.tsx        — init i18next côté client (backend CDN de Locize)
├── entry.server.tsx        — renderer côté serveur, reçoit l'instance i18next du middleware
├── routes.ts               — configuration des routes
├── middleware/
│   └── i18next.ts          — configuration de createI18nextMiddleware
├── locales/                — traductions empaquetées (JSON, synchronisées depuis Locize)
│   ├── en/{common,index,second}.json
│   ├── de/{common,index,second}.json
│   └── index.ts            — ré-exporte tous les JSON comme un seul objet `resources`
└── routes/
    ├── home.tsx
    └── second.tsx

Remarque : les fichiers JSON de traduction se trouvent sous app/locales/ (pas public/locales/), parce que Vite ne met pas en bundle les imports statiques depuis public/. C'est l'emplacement le plus propre pour les traductions qui doivent être importées comme modules ES côté serveur.

Installer i18next + remix-i18next + Locize

npm install remix-i18next i18next react-i18next \
  i18next-browser-languagedetector i18next-locize-backend \
  locize locize-lastused
npm install --save-dev locize-cli

Activer le middleware dans react-router.config.ts

Le support du middleware se trouve actuellement derrière un future flag dans React Router v7 :

import type { Config } from '@react-router/dev/config'

export default {
  ssr: true,
  future: {
    v8_middleware: true,
  },
} satisfies Config

Empaqueter les traductions

Exportez chaque langue × chaque namespace en un objet, afin de pouvoir le passer à i18next côté serveur :

// app/locales/index.ts
import enCommon from './en/common.json'
import enIndex from './en/index.json'
import enSecond from './en/second.json'
import deCommon from './de/common.json'
import deIndex from './de/index.json'
import deSecond from './de/second.json'

export default {
  en: {
    common: enCommon,
    index: enIndex,
    second: enSecond,
  },
  de: {
    common: deCommon,
    index: deIndex,
    second: deSecond,
  },
}

Le middleware — app/middleware/i18next.ts

C'est le cœur du nouveau pattern. createI18nextMiddleware renvoie un tuple à 3 éléments : le middleware lui-même plus des accesseurs pour la locale et l'instance i18next :

import { initReactI18next } from 'react-i18next'
import { createCookie } from 'react-router'
import { createI18nextMiddleware } from 'remix-i18next/middleware'
import resources from '~/locales'

export const localeCookie = createCookie('lng', {
  path: '/',
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
  httpOnly: true,
})

export const [i18nextMiddleware, getLocale, getInstance] =
  createI18nextMiddleware({
    detection: {
      supportedLanguages: ['en', 'de'],
      fallbackLanguage: 'en',
      cookie: localeCookie,
    },
    i18next: {
      resources,
      fallbackLng: 'en',
      supportedLngs: ['en', 'de'],
      defaultNS: 'common',
      ns: ['common', 'index', 'second'],
    },
    plugins: [initReactI18next],
  })

Brancher le middleware dans root.tsx

import { useEffect } from 'react'
import {
  data,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from 'react-router'
import { useTranslation } from 'react-i18next'
import type { Route } from './+types/root'
import {
  getLocale,
  i18nextMiddleware,
  localeCookie,
} from './middleware/i18next'

// Déclaration du middleware — s'exécute pour chaque requête via la route racine.
export const middleware = [i18nextMiddleware]

export async function loader({ context }: Route.LoaderArgs) {
  const locale = getLocale(context)
  return data(
    { locale },
    { headers: { 'Set-Cookie': await localeCookie.serialize(locale) } },
  )
}

export function Layout({ children }: { children: React.ReactNode }) {
  const { i18n } = useTranslation()
  return (
    <html lang={i18n.language} dir={i18n.dir(i18n.language)}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  )
}

export default function App({ loaderData: { locale } }: Route.ComponentProps) {
  const { i18n } = useTranslation()
  useEffect(() => {
    if (i18n.language !== locale) i18n.changeLanguage(locale)
  }, [locale, i18n])
  return <Outlet />
}

Rendu côté serveur — entry.server.tsx

getInstance(routerContext) renvoie l'instance i18next par requête que le middleware a initialisée. Enveloppez l'arbre React dans I18nextProvider, afin que useTranslation() résolve la bonne instance pendant le SSR :

import { PassThrough } from 'node:stream'
import { createReadableStreamFromReadable } from '@react-router/node'
import type { EntryContext, RouterContextProvider } from 'react-router'
import { ServerRouter } from 'react-router'
import { isbot } from 'isbot'
import type { RenderToPipeableStreamOptions } from 'react-dom/server'
import { renderToPipeableStream } from 'react-dom/server'
import { I18nextProvider } from 'react-i18next'
import { getInstance } from './middleware/i18next'

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  entryContext: EntryContext,
  routerContext: RouterContextProvider,
) {
  const i18nInstance = getInstance(routerContext)

  return new Promise((resolve, reject) => {
    const userAgent = request.headers.get('user-agent')
    const readyOption: keyof RenderToPipeableStreamOptions =
      (userAgent && isbot(userAgent)) || entryContext.isSpaMode
        ? 'onAllReady'
        : 'onShellReady'

    const { pipe, abort } = renderToPipeableStream(
      <I18nextProvider i18n={i18nInstance}>
        <ServerRouter context={entryContext} url={request.url} />
      </I18nextProvider>,
      {
        [readyOption]() {
          const body = new PassThrough()
          responseHeaders.set('Content-Type', 'text/html')
          resolve(
            new Response(createReadableStreamFromReadable(body), {
              headers: responseHeaders,
              status: responseStatusCode,
            }),
          )
          pipe(body)
        },
        onShellError(error) { reject(error) },
        onError(error) {
          responseStatusCode = 500
          console.error(error)
        },
      },
    )

    setTimeout(abort, 6_000)
  })
}

Catch-all + fallback défensif. Ajoutez route('*', './routes/not-found.tsx') dans routes.ts pour que le middleware tourne toujours (y compris pour les probes comme /.well-known/... que Chrome DevTools déclenche). Et enveloppez getInstance(...) dans try/catch avec une instance i18next minimale de fallback si vous voulez plus de résilience face aux cas limites. Le repo d'exemple montre les deux.

Initialisation côté client — entry.client.tsx

Ici, Locize devient la source des traductions fraîches. Sur le client, i18next-locize-backend récupère les traductions directement depuis le CDN Locize. Les mises à jour publiées apparaissent ainsi à la prochaine vue de page sans redéploiement. Le plugin d'édition in-context locize permet aux traducteurs d'ouvrir ?incontext=true et de modifier en ligne.

import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { HydratedRouter } from 'react-router/dom'
import i18next from 'i18next'
import { I18nextProvider, initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import Backend from 'i18next-locize-backend'
import LastUsed from 'locize-lastused'
import { locizePlugin } from 'locize'

const isProduction = import.meta.env.PROD

const locizeOptions = {
  projectId: '<your locize project id>',
  // Seulement en dev — pousse les nouvelles clés via saveMissing. Ne jamais
  // empaqueter une clé avec droits d'écriture en production. Dans un projet
  // réel, chargez-la depuis un fichier env gitignored.
  apiKey: !isProduction ? '<your dev apiKey>' : undefined,
  version: isProduction ? 'production' : 'latest',
  // 'standard' → api.lite.locize.app (BunnyCDN, gratuit, par défaut pour les nouveaux projets)
  // 'pro'      → api.locize.app (CloudFront, prend en charge Private Downloads)
  cdnType: 'pro' as const,
}

async function main() {
  if (!isProduction) i18next.use(LastUsed)

  await i18next
    .use(locizePlugin)
    .use(Backend)
    .use(LanguageDetector)
    .use(initReactI18next)
    .init({
      fallbackLng: 'en',
      supportedLngs: ['en', 'de'],
      defaultNS: 'common',
      ns: ['common', 'index', 'second'],
      // Le serveur a déjà décidé de la locale et l'a émise via
      // <html lang> — on la relit, sans la détecter à nouveau.
      detection: { order: ['htmlTag'], caches: [] },
      backend: locizeOptions,
      locizeLastUsed: locizeOptions,
      saveMissing: !isProduction,
      react: { useSuspense: false },
    })

  startTransition(() => {
    hydrateRoot(
      document,
      <I18nextProvider i18n={i18next}>
        <StrictMode>
          <HydratedRouter />
        </StrictMode>
      </I18nextProvider>,
    )
  })
}

main().catch((error) => console.error(error))

Utiliser t() dans les routes

Comme dans n'importe quelle app react-i18next :

// app/routes/home.tsx
import { Link } from 'react-router'
import { useTranslation, Trans } from 'react-i18next'

export const handle = { i18n: ['index'] }

export default function Home() {
  const { t, i18n } = useTranslation('index')
  return (
    <main>
      <h1>{t('title')}</h1>
      <p>
        <Trans t={t} i18nKey="description.part1">
          To get started, edit <code>app/routes/home.tsx</code> and save to reload.
        </Trans>
      </p>
      <p>{t('description.part2')}</p>
      <Link to="/second">{t('goto.second')}</Link>
    </main>
  )
}

Endpoint du CDN Locize

Locize propose deux infrastructures CDN (comparaison complète dans Types de CDN : Standard vs Pro) :

  • CDN Standard sur api.lite.locize.app, basé sur BunnyCDN, gratuit pour des volumes mensuels de téléchargement généreux, cache fixe d'une heure, public uniquement. Par défaut pour les projets Locize nouvellement créés.
  • CDN Pro sur api.locize.app, basé sur CloudFront, payant, prend en charge Private Downloads, contrôle de cache personnalisé, sauvegardes de namespaces.

Définissez cdnType: 'standard' ou 'pro' dans l'objet locizeOptions ci-dessus selon votre projet.

Localisation continue avec Locize

Une fois l'intégration câblée, la partie workflow est ce qui donne sa valeur à Locize :

Synchroniser les traductions depuis Locize lors du build

Ajoutez des scripts à package.json qui invoquent la locize-cli :

{
  "scripts": {
    "downloadLocales": "locize download --project-id=<your-id> --ver=latest --cdn-type=pro --clean=true --path=./app/locales",
    "syncLocales": "locize sync --project-id=<your-id> --ver=latest --cdn-type=pro --api-key=<your-write-key> --path=./app/locales --dry=true"
  }
}

Exécutez npm run downloadLocales avant npm run build (en local, en CI ou comme hook prebuild) pour garantir que les traductions empaquetées côté serveur sont fraîches.

saveMissing — les clés circulent automatiquement du code vers Locize

Lorsque saveMissing: true est défini côté client et qu'une apiKey est configurée, chaque clé de traduction que votre app référence mais que le JSON chargé ne contient pas encore est renvoyée à votre projet Locize. Les traducteurs voient les nouvelles clés dans l'interface Locize sans étape d'extraction manuelle.

En production, omettez l'apiKey (comme dans le snippet ci-dessus), pour que les tentatives d'écriture deviennent des no-op. Le chemin de lecture depuis le CDN continue de fonctionner pour toutes les clés traduites.

Édition in-context — ?incontext=true

Le plugin locize (simplement .use(locizePlugin) dans la chaîne d'init) donne aux traducteurs un éditeur in-context : ouvrez une page de votre app en cours d'exécution avec ?incontext=true ajouté, et un éditeur basé sur iframe s'ouvre, qui met en évidence chaque chaîne traduite et permet aux traducteurs de la modifier sur place.

Reporting last-used + traduction automatique

  • locize-lastused (seulement en dev) marque chaque segment de traduction avec son horodatage de dernier accès, pour pouvoir nettoyer les clés inutilisées plus tard.
  • L'interface Locize propose la traduction automatique en un clic pour les clés manquantes via DeepL / OpenAI / Google Translate. Vous pouvez ainsi avoir un build allemand fonctionnel quelques minutes après avoir ajouté le texte anglais.

🎉 Félicitations

Une configuration React Router v7 + remix-i18next + Locize fonctionnelle vous offre :

  • i18n compatible SSR sans flash de contenu non traduit
  • Détection de la locale par requête via middleware (Cookie → URL → Accept-Language → Fallback)
  • Traductions fraîches sans redéploiement via le CDN Locize
  • saveMissing saute l'étape d'extraction, les nouvelles clés apparaissent dans Locize dès que les développeurs les référencent
  • Édition in-context pour les non-développeurs via le plugin locize
  • Synchronisation des traductions compatible CI via locize-cli

🧑‍💻 Le code complet : github.com/locize/locize-react-router-example.

Les fondateurs de Locize sont aussi les créateurs d'i18next. En utilisant Locize, vous soutenez directement l'avenir d'i18next.

Si vous voulez un panorama plus large d'i18next, il existe aussi une vidéo crash course i18next :

smart_display
YouTube Video
This video is hosted on YouTube. Accept YouTube cookies to watch it here.
Watch on YouTube

Voir aussi