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-backendsur 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) :
| Domaine | Remix v2 / remix-i18next 6 | React Router v7 / remix-i18next 7 |
|---|---|---|
| Détection de la locale | Une classe RemixI18Next instanciée une seule fois au chargement du module, appelée explicitement depuis les loaders | Un 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 loader | remixI18n.getLocale(request) / remixI18n.getFixedT(request, ns) | getLocale(context) / getInstance(context) depuis le tuple renvoyé par le middleware |
| Système de build | Compilateur classique de Remix ou plugin Vite de Remix | Plugin 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-appPour 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.tsxRemarque : 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-cliActiver 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 ConfigEmpaqueter 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')dansroutes.tspour que le middleware tourne toujours (y compris pour les probes comme/.well-known/...que Chrome DevTools déclenche). Et enveloppezgetInstance(...)danstry/catchavec 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
saveMissingsaute 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 :
Voir aussi
- Comment internationaliser une application Remix (Partie 1) — walkthrough de Remix v2 (toujours pertinent si vous n'êtes pas passé à RR v7)
- Comment internationaliser une application Remix (Partie 2) — workflow de localisation continue avec Locize sur Remix v2
- remix-i18next — la bibliothèque sur laquelle s'appuie cet article (merci à Sergio Xalambrí)