Skip to content
May 19, 20265 min readTutorials

How to internationalize a React Router v7 app with remix-i18next

In May 2024 the Remix team announced that Remix as a separate framework would merge into React Router v7, with the Remix-style developer experience becoming "framework mode" of React Router. The i18n library most Remix apps use, remix-i18next, followed the move: starting with version 7.x it targets React Router v7 framework mode (and its 6.x line continues to support Remix v2).

This post walks through the new shape: how to localize a React Router v7 app with i18next, remix-i18next 7.x, and the Locize translation management platform — including the new React Router middleware pattern, server-side rendering with bundled translations, and client-side CDN-backed lazy loading.

If you're still on Remix v2, follow the Remix v2 post instead and keep using remix-i18next 6.x.

If you're on React Router v7 in SPA mode (no SSR), use react-i18next directly — this post focuses on framework-mode + SSR.

TL;DR

  • Use remix-i18next 7.x's middleware (createI18nextMiddleware) to detect locale per request and seed a per-request i18next instance.
  • Bundle translations server-side via static JSON imports (no runtime CDN hits — serverless-friendly).
  • Use i18next-locize-backend on the client to lazy-load fresh translations from the Locize CDN — published updates appear without redeploying.
  • Push missing keys back to Locize automatically with saveMissing.
  • Edit in context with the locize plugin (append ?incontext=true to any URL).
  • Full working example: github.com/locize/locize-react-router-example

What changed from Remix v2

Three pieces of the integration changed materially between remix-i18next 6.x (Remix v2) and 7.x (React Router v7):

ConcernRemix v2 / remix-i18next 6React Router v7 / remix-i18next 7
Locale detection wiringA RemixI18Next class instantiated once at module load, called explicitly from loadersA route middleware (createI18nextMiddleware) declared in root.tsx that runs per request and seeds context
Loader-side translation accessremixI18n.getLocale(request) / remixI18n.getFixedT(request, ns)getLocale(context) / getInstance(context) from the middleware's return tuple
Build systemRemix classic compiler or Remix Vite pluginReact Router Vite plugin (@react-router/dev/vite) — Vite is the only path forward

The translation-library plugins (i18next-locize-backend, locize, locize-lastused, i18next-browser-languagedetector) are unchanged.

Let's get into it

Prerequisites

Make sure you have Node.js 20+ and a recent version of npm/pnpm/yarn. You should be comfortable with React and have read the React Router v7 framework mode docs at least once.

Project layout

You can scaffold a fresh React Router v7 app with the official starter:

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

For the rest of this post, the relevant pieces of the app layout are:

app/
├── root.tsx                — declares the i18next middleware
├── entry.client.tsx        — client-side i18next init (Locize CDN backend)
├── entry.server.tsx        — server-side renderer, picks up the middleware's i18next
├── routes.ts               — route configuration
├── middleware/
│   └── i18next.ts          — createI18nextMiddleware setup
├── locales/                — bundled translations (JSON synced from Locize)
│   ├── en/{common,index,second}.json
│   ├── de/{common,index,second}.json
│   └── index.ts            — re-exports all JSON as a single `resources` object
└── routes/
    ├── home.tsx
    └── second.tsx

Note: the JSON translation files live under app/locales/ (not public/locales/) because Vite refuses to bundle static imports from public/. This is the cleanest place for translations that need to be imported as ES modules server-side.

Install 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

Enable middleware in react-router.config.ts

The middleware support is currently behind a future flag in React Router v7:

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

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

Bundle the translations

Export every language × namespace as one object so it can be passed to i18next on the server:

// 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,
  },
}

The middleware — app/middleware/i18next.ts

This is the heart of the new pattern. createI18nextMiddleware returns a 3-tuple: the middleware itself, plus accessor functions for the locale and the i18next instance:

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],
  })

Hook the middleware in 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'

// Declare the middleware — runs for every request through the 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 />
}

Server-side rendering — entry.server.tsx

getInstance(routerContext) returns the per-request i18next instance the middleware seeded. Wrap the React tree in I18nextProvider so useTranslation() resolves the right instance during 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 + defensive fallback. Add a route('*', './routes/not-found.tsx') in routes.ts so the middleware always runs (including for probes like /.well-known/... Chrome DevTools fires). And wrap getInstance(...) in a try/catch with a minimal fallback i18next instance if you want extra resilience against edge cases. The example repo shows both.

Client-side init — entry.client.tsx

This is where Locize becomes the fresh-translation source. On the client, i18next-locize-backend fetches translations directly from the Locize CDN — so published updates appear on the next page view without a redeploy. The locize in-context editor plugin lets translators open ?incontext=true and edit inline.

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>',
  // Dev-only — pushes new keys back via saveMissing. Never bundle a
  // write-enabled key in production. In a real project, source this
  // from a git-ignored env file.
  apiKey: !isProduction ? '<your dev apiKey>' : undefined,
  version: isProduction ? 'production' : 'latest',
  // 'standard' → api.lite.locize.app (BunnyCDN, free, default for new projects)
  // 'pro'      → api.locize.app (CloudFront, supports 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'],
      // The server already decided the locale and emitted it via
      // <html lang> — just read it back, don't redetect on the client.
      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))

Using t() in routes

Same as in any 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 ships two CDN infrastructures (full comparison at CDN types: Standard vs. Pro):

  • Standard CDN at api.lite.locize.app — BunnyCDN-backed, free for generous monthly download volumes, 1-hour fixed cache, public-only. Default for newly created Locize projects.
  • Pro CDN at api.locize.app — CloudFront-backed, paid, supports private downloads, custom cache control, namespace backups.

Set cdnType: 'standard' or 'pro' in the locizeOptions object above to match your project.

Continuous localization with Locize

Once the integration is wired, the workflow piece is what makes Locize worth its keep:

Sync translations from Locize at build time

Add scripts to package.json that call the 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"
  }
}

Run npm run downloadLocales before npm run build (locally, in CI, or as a prebuild hook) to ensure the server-side bundled translations are fresh.

saveMissing — keys flow from code → Locize automatically

When saveMissing: true is set client-side and an apiKey is configured, every translation key your app references but the loaded JSON doesn't yet contain is pushed back to your Locize project. Translators see the new keys appear in the Locize UI without any manual extract step.

In production, omit the apiKey (as in the snippet above) so write attempts become no-ops — the CDN-served read path still works for all translated keys.

In-context editing — ?incontext=true

The locize plugin (just .use(locizePlugin) in the init chain) gives translators an in-context editor: open any page of your running app with ?incontext=true appended and an iframe-based editor opens that highlights every translated string and lets translators edit them in place.

Last-used reporting + automatic translation

  • locize-lastused (dev-only) tags every translation segment with its last-access timestamp, so unused keys can be cleaned up later.
  • Locize's UI offers one-click automatic translation for missing keys via DeepL / OpenAI / Google Translate — so you can have a working German build minutes after adding English copy.

🎉 Congratulations

A working React Router v7 + remix-i18next + Locize setup gives you:

  • SSR-friendly i18n with no flash of untranslated content
  • Per-request locale detection via middleware (cookie → URL → Accept-Language → fallback)
  • Fresh translations without redeploying via the Locize CDN
  • saveMissing to skip the extract step — new keys appear in Locize as developers reference them
  • In-context editing for non-developers via the locize plugin
  • CI-friendly translation sync via locize-cli

🧑‍💻 The complete code: github.com/locize/locize-react-router-example.

The founders of Locize are also the creators of i18next — by using Locize you directly support the future of i18next.

If you want a bigger i18next overview, there's also an i18next crash course video:

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

See also