React Router v7 mit remix-i18next internationalisieren
Im Mai 2024 hat das Remix-Team angekündigt, dass Remix als eigenständiges Framework in React Router v7 integriert wird — und das Remix-typische Developer-Erlebnis zum „Framework-Modus" von React Router wird. Die i18n-Bibliothek, die die meisten Remix-Apps nutzen, remix-i18next, ist dem Schritt gefolgt: ab Version 7.x zielt sie auf React Router v7 Framework-Modus (und die 6.x-Linie unterstützt weiterhin Remix v2).
Dieser Beitrag führt durch die neue Form: wie Sie eine React-Router-v7-App mit i18next, remix-i18next 7.x und der Locize-Übersetzungsmanagement-Plattform lokalisieren — inklusive des neuen React-Router-Middleware-Musters, serverseitiges Rendering mit gebündelten Übersetzungen und client-seitiges CDN-gestütztes Lazy Loading.
Wenn Sie noch auf Remix v2 sind, folgen Sie stattdessen dem Remix-v2-Beitrag und bleiben Sie bei remix-i18next 6.x.
Wenn Sie React Router v7 im SPA-Modus (ohne SSR) nutzen, verwenden Sie react-i18next direkt — dieser Beitrag fokussiert auf Framework-Modus + SSR.
TL;DR
- Nutzen Sie remix-i18next 7.x Middleware (
createI18nextMiddleware), um die Locale pro Request zu erkennen und eine i18next-Instanz pro Request zu initialisieren. - Bündeln Sie Übersetzungen serverseitig über statische JSON-Importe (keine Runtime-CDN-Calls — serverless-freundlich).
- Nutzen Sie
i18next-locize-backendauf dem Client, um frische Übersetzungen vom Locize-CDN per Lazy Load zu holen — veröffentlichte Updates erscheinen ohne Redeployment. - Senden Sie fehlende Schlüssel automatisch zurück an Locize mit
saveMissing. - Bearbeiten Sie im Kontext mit dem
locize-Plugin (?incontext=truean eine URL anhängen). - Vollständiges Beispiel: github.com/locize/locize-react-router-example
Was sich gegenüber Remix v2 geändert hat
Drei Teile der Integration haben sich zwischen remix-i18next 6.x (Remix v2) und 7.x (React Router v7) wesentlich geändert:
| Bereich | Remix v2 / remix-i18next 6 | React Router v7 / remix-i18next 7 |
|---|---|---|
| Locale-Erkennung | Eine RemixI18Next-Klasse einmal beim Modul-Laden instanziiert, explizit aus Loadern aufgerufen | Eine Route-Middleware (createI18nextMiddleware) in root.tsx deklariert, die pro Request läuft und den Kontext initialisiert |
| Übersetzungs-Zugriff im Loader | remixI18n.getLocale(request) / remixI18n.getFixedT(request, ns) | getLocale(context) / getInstance(context) aus dem Rückgabe-Tupel der Middleware |
| Build-System | Remix-Classic-Compiler oder Remix Vite Plugin | React Router Vite Plugin (@react-router/dev/vite) — Vite ist der einzige Weg nach vorn |
Die Übersetzungs-Plugins (i18next-locize-backend, locize, locize-lastused, i18next-browser-languagedetector) bleiben unverändert.
Legen wir los
Voraussetzungen
Stellen Sie sicher, dass Sie Node.js 20+ und eine aktuelle Version von npm/pnpm/yarn haben. Sie sollten mit React vertraut sein und die React Router v7 Framework-Modus-Docs mindestens einmal gelesen haben.
Projekt-Layout
Sie können eine frische React-Router-v7-App mit dem offiziellen Starter erzeugen:
npx create-react-router@latest my-app
cd my-appFür den Rest dieses Beitrags sind die relevanten Teile des App-Layouts:
app/
├── root.tsx — deklariert die i18next-Middleware
├── entry.client.tsx — client-seitige i18next-Init (Locize-CDN-Backend)
├── entry.server.tsx — server-seitiger Renderer, übernimmt die i18next-Instanz der Middleware
├── routes.ts — Routenkonfiguration
├── middleware/
│ └── i18next.ts — createI18nextMiddleware-Setup
├── locales/ — gebündelte Übersetzungen (JSON, synchronisiert aus Locize)
│ ├── en/{common,index,second}.json
│ ├── de/{common,index,second}.json
│ └── index.ts — re-exportiert alle JSONs als ein `resources`-Objekt
└── routes/
├── home.tsx
└── second.tsxHinweis: Die JSON-Übersetzungsdateien liegen unter app/locales/ (nicht public/locales/), weil Vite statische Importe aus public/ nicht bündelt. Das ist der sauberste Ort für Übersetzungen, die serverseitig als ES-Module importiert werden müssen.
i18next + remix-i18next + Locize installieren
npm install remix-i18next i18next react-i18next \
i18next-browser-languagedetector i18next-locize-backend \
locize locize-lastused
npm install --save-dev locize-cliMiddleware in react-router.config.ts aktivieren
Die Middleware-Unterstützung ist aktuell hinter einem Future-Flag in React Router v7:
import type { Config } from '@react-router/dev/config'
export default {
ssr: true,
future: {
v8_middleware: true,
},
} satisfies ConfigÜbersetzungen bündeln
Exportieren Sie jede Sprache × jeden Namespace als ein Objekt, damit es serverseitig an i18next übergeben werden kann:
// 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,
},
}Die Middleware — app/middleware/i18next.ts
Das ist der Kern des neuen Musters. createI18nextMiddleware gibt ein 3-Tupel zurück: die Middleware selbst plus Accessor-Funktionen für Locale und i18next-Instanz:
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],
})Die Middleware in root.tsx einhängen
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'
// Middleware deklarieren — läuft für jeden Request durch die Root-Route.
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 />
}Serverseitiges Rendering — entry.server.tsx
getInstance(routerContext) gibt die i18next-Instanz pro Request zurück, die die Middleware initialisiert hat. Wickeln Sie den React-Baum in I18nextProvider, damit useTranslation() während SSR die richtige Instanz auflöst:
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 + defensiver Fallback. Fügen Sie
route('*', './routes/not-found.tsx')inroutes.tshinzu, damit die Middleware immer läuft (einschließlich Probes wie/.well-known/..., die Chrome DevTools auslöst). Und wickeln SiegetInstance(...)intry/catchmit einer minimalen Fallback-i18next-Instanz, wenn Sie extra Resilienz gegen Edge-Cases möchten. Das Beispiel-Repo zeigt beides.
Client-seitige Initialisierung — entry.client.tsx
Hier wird Locize zur Quelle für frische Übersetzungen. Auf dem Client holt i18next-locize-backend Übersetzungen direkt vom Locize-CDN — veröffentlichte Updates erscheinen also beim nächsten Page-View ohne Redeployment. Das locize-In-Context-Editor-Plugin erlaubt Übersetzern, ?incontext=true zu öffnen und inline zu bearbeiten.
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>',
// Nur in Dev — pusht neue Schlüssel via saveMissing zurück. Niemals einen
// schreibfähigen Key in Produktion bündeln. In einem echten Projekt aus
// einer gitignored env-Datei laden.
apiKey: !isProduction ? '<your dev apiKey>' : undefined,
version: isProduction ? 'production' : 'latest',
// 'standard' → api.lite.locize.app (BunnyCDN, kostenlos, Default für neue Projekte)
// 'pro' → api.locize.app (CloudFront, unterstützt 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'],
// Der Server hat die Locale bereits entschieden und sie via
// <html lang> emittiert — einfach zurücklesen, nicht erneut erkennen.
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))t() in Routes verwenden
Wie in jeder react-i18next-App:
// 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>
)
}Locize-CDN-Endpoint
Locize liefert zwei CDN-Infrastrukturen (vollständiger Vergleich unter CDN-Typen: Standard vs. Pro):
- Standard-CDN unter
api.lite.locize.app— BunnyCDN-gestützt, kostenlos für großzügige monatliche Download-Volumen, 1-Stunden-Festcache, nur öffentlich. Default für neu erstellte Locize-Projekte. - Pro-CDN unter
api.locize.app— CloudFront-gestützt, kostenpflichtig, unterstützt Private Downloads, individuelle Cache-Steuerung, Namespace-Backups.
Setzen Sie cdnType: 'standard' oder 'pro' im locizeOptions-Objekt oben passend zu Ihrem Projekt.
Kontinuierliche Lokalisierung mit Locize
Sobald die Integration verdrahtet ist, ist der Workflow-Teil das, was Locize seinen Wert verleiht:
Übersetzungen beim Build von Locize synchronisieren
Fügen Sie Skripte zu package.json hinzu, die das locize-cli aufrufen:
{
"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"
}
}Führen Sie npm run downloadLocales vor npm run build aus (lokal, in CI oder als prebuild-Hook), um sicherzustellen, dass die serverseitig gebündelten Übersetzungen frisch sind.
saveMissing — Schlüssel fließen automatisch vom Code zu Locize
Wenn saveMissing: true client-seitig gesetzt und ein apiKey konfiguriert ist, wird jeder Übersetzungs-Schlüssel, den Ihre App referenziert, aber das geladene JSON noch nicht enthält, an Ihr Locize-Projekt zurückgepusht. Übersetzer sehen die neuen Schlüssel in der Locize-UI ohne manuellen Extraktions-Schritt.
In Produktion lassen Sie den apiKey weg (wie im Snippet oben), damit Schreibversuche No-Ops werden — der CDN-Lese-Pfad funktioniert weiterhin für alle übersetzten Schlüssel.
In-Context-Editing — ?incontext=true
Das locize-Plugin (einfach .use(locizePlugin) in der Init-Kette) gibt Übersetzern einen In-Context-Editor: öffnen Sie eine Seite Ihrer laufenden App mit angehängtem ?incontext=true, und ein iframe-basierter Editor öffnet sich, der jeden übersetzten String hervorhebt und Übersetzern erlaubt, sie an Ort und Stelle zu bearbeiten.
Last-used-Reporting + automatische Übersetzung
locize-lastused(nur in Dev) markiert jedes Übersetzungs-Segment mit seinem letzten Zugriffs-Zeitstempel, sodass ungenutzte Schlüssel später aufgeräumt werden können.- Die Locize-UI bietet One-Click-automatische Übersetzung für fehlende Schlüssel via DeepL / OpenAI / Google Translate — sodass Sie Minuten nach dem Hinzufügen englischer Copy einen funktionierenden deutschen Build haben können.
🎉 Glückwunsch
Ein funktionierendes React Router v7 + remix-i18next + Locize-Setup gibt Ihnen:
- SSR-taugliches i18n ohne Flash unübersetzter Inhalte
- Pro-Request-Locale-Erkennung via Middleware (Cookie → URL → Accept-Language → Fallback)
- Frische Übersetzungen ohne Redeployment via Locize-CDN
saveMissingüberspringt den Extraktions-Schritt — neue Schlüssel erscheinen in Locize, sobald Entwickler sie referenzieren- In-Context-Editing für Nicht-Entwickler via
locize-Plugin - CI-tauglicher Übersetzungs-Sync via
locize-cli
🧑💻 Der vollständige Code: github.com/locize/locize-react-router-example.
Die Gründer von Locize sind auch die Macher von i18next — durch die Nutzung von Locize unterstützen Sie direkt die Zukunft von i18next.
Wenn Sie eine größere i18next-Übersicht möchten, gibt es auch ein i18next-Crash-Kurs-Video:
Siehe auch
- Wie man eine Remix-Anwendung internationalisiert (Teil 1) — Remix v2 Walkthrough (weiterhin relevant, wenn Sie noch nicht auf RR v7 sind)
- Wie man eine Remix-Anwendung internationalisiert (Teil 2) — kontinuierlicher Lokalisierungs-Workflow mit Locize auf Remix v2
- remix-i18next — die Bibliothek, auf der dieser Beitrag aufbaut (Dank an Sergio Xalambrí)