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-backendon 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
locizeplugin (append?incontext=trueto 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):
| Concern | Remix v2 / remix-i18next 6 | React Router v7 / remix-i18next 7 |
|---|---|---|
| Locale detection wiring | A RemixI18Next class instantiated once at module load, called explicitly from loaders | A route middleware (createI18nextMiddleware) declared in root.tsx that runs per request and seeds context |
| Loader-side translation access | remixI18n.getLocale(request) / remixI18n.getFixedT(request, ns) | getLocale(context) / getInstance(context) from the middleware's return tuple |
| Build system | Remix classic compiler or Remix Vite plugin | React 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-appFor 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.tsxNote: 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-cliEnable 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 ConfigBundle 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')inroutes.tsso the middleware always runs (including for probes like/.well-known/...Chrome DevTools fires). And wrapgetInstance(...)in atry/catchwith 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
saveMissingto skip the extract step — new keys appear in Locize as developers reference them- In-context editing for non-developers via the
locizeplugin - 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:
See also
- How to internationalize a Remix application (Part 1) — Remix v2 walkthrough (still relevant if you're not yet on RR v7)
- How to internationalize a Remix application (Part 2) — continuous localization workflow with Locize on Remix v2
- remix-i18next — the library this post is built around (thank you Sergio Xalambrí)