Zum Inhalt springen
19. Mai 20265 min readTutorials

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-backend auf 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=true an 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:

BereichRemix v2 / remix-i18next 6React Router v7 / remix-i18next 7
Locale-ErkennungEine RemixI18Next-Klasse einmal beim Modul-Laden instanziiert, explizit aus Loadern aufgerufenEine Route-Middleware (createI18nextMiddleware) in root.tsx deklariert, die pro Request läuft und den Kontext initialisiert
Übersetzungs-Zugriff im LoaderremixI18n.getLocale(request) / remixI18n.getFixedT(request, ns)getLocale(context) / getInstance(context) aus dem Rückgabe-Tupel der Middleware
Build-SystemRemix-Classic-Compiler oder Remix Vite PluginReact 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-app

Fü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.tsRoutenkonfiguration
├── 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.tsx

Hinweis: 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-cli

Middleware 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') in routes.ts hinzu, damit die Middleware immer läuft (einschließlich Probes wie /.well-known/..., die Chrome DevTools auslöst). Und wickeln Sie getInstance(...) in try/catch mit 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:

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

Siehe auch