# remix-i18next: one i18next instance for loaders and the React tree

> remix-i18next wires i18next into React Router framework mode apps so the server and the browser share a single configured instance, with the detected locale readable from loaders. The typing story is the strongest part, and the locale serving route is the part you still have to write yourself.

**sergiodxa/remix-i18next** — The easiest way to translate your React Router framework mode apps

- Repository: https://github.com/sergiodxa/remix-i18next
- Website: https://sergiodxa.github.io/remix-i18next/
- Stars: 714 · Forks: 55
- Language: TypeScript
- License: MIT
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/sergiodxa-remix-i18next

## One factory call returns the middleware, the locale getter and the instance

Everything in remix-i18next hangs off one factory, createI18nextMiddleware, which returns a triple that is destructured into three names: the middleware, a getLocale function and a getInstance function. The middleware is registered on the root route, the getter reads the active locale out of the router context, and the second getter hands back the configured i18next object to loaders and actions.

That single source is the point of the library. A string translated in a loader and a string translated in a component come from the same resource bundle and the same fallback language, so a page cannot end up half in one language and half in another because two separate i18next setups disagreed.

Detection is configured in the same place, with supportedLanguages and fallbackLanguage passed into the factory. The README does not spell out the order in which request headers, cookies and detector results are consulted, so if your app depends on a specific precedence between a URL segment and a browser preference, confirm that behaviour in your own code rather than assuming it.

## Type alignment, not a runtime fallback, is what catches a missing key

The locale files are typed rather than merely written. The default locale exports a plain object, and every other locale declares satisfies against the type of the default locale file, so a missing key or a renamed key in a translation file is a type error at build time instead of a silent fallback to the default language. The README calls the default locale the source of truth for exactly that reason.

Two more type hooks sit on top of it. Each locale directory re-exports its object with satisfies ResourceLanguage, taken from i18next, and a single entry point collects the locales with satisfies Resource. The middleware module then augments the i18next module through a declare module block that sets defaultNS and the shape of resources.

The practical effect is that t("title") is checked against the real resource object instead of an untyped string, and the default namespace no longer has to be repeated at every call site. For a team adding a dozen locales over a year, that is the difference between learning about a missing key from the compiler and learning about it from a user report in a language you do not read.

## Install the four packages, then add the fetch backend if the browser loads locales on demand

Start with the packages that carry translation state, the React bindings and language detection. The backend is a separate install, needed only when the browser should load translation files on demand, and the example picks the fetch backend because it works well with a route that serves locale data.

```sh
npm install remix-i18next i18next react-i18next i18next-browser-languagedetector
```

```sh
npm install i18next-fetch-backend
```

Then define the default locale and at least one translated locale. The default locale file is a plain object, and its directory index re-exports it typed as a ResourceLanguage.

```ts
// app/locales/en/translation.ts
export default {
	title: "remix-i18next (en)",
	description: "A React Router + remix-i18next example",
};

// app/locales/en/index.ts
import type { ResourceLanguage } from "i18next";
import translation from "./translation";

export default { translation } satisfies ResourceLanguage;
```

A second locale repeats the same keys and is checked against the default one, so a rename in the English file breaks the build in every other locale file. A single entry point then collects the locales for the middleware to consume.

```ts
// app/locales/index.ts
import type { Resource } from "i18next";
import en from "./en";
import es from "./es";

export default { en, es } satisfies Resource;
```

## The middleware module wires resources, detection and the React plugin together

Next comes the middleware module, which is where resources, detection and the React binding meet. The shape of the return value matters: one destructuring statement gives you the middleware for the route export, the locale getter and the instance getter.

```ts
// app/middleware/i18next.ts
import "i18next";
import { initReactI18next } from "react-i18next";
import { createI18nextMiddleware } from "remix-i18next";
import resources from "~/locales";

export const [i18nextMiddleware, getLocale, getInstance] = createI18nextMiddleware({
	detection: {
		supportedLanguages: ["es", "en"],
		fallbackLanguage: "en",
	},
	i18next: { resources },
	plugins: [initReactI18next],
});
```

The module augmentation comes next, and it is what makes the translation calls type safe.

```ts
declare module "i18next" {
	interface CustomTypeOptions {
		defaultNS: "translation";
		resources: typeof resources.en;
	}
}
```

Registering the middleware on the root route is a single export, and it is the only place the route tree has to know about i18n.

```ts
// app/root.tsx
import { i18nextMiddleware } from "~/middleware/i18next";

export const middleware = [i18nextMiddleware];
```

## Loaders read the locale from the router context, not from a global

With the middleware registered, a loader asks the context for the locale and for the instance, then translates through the instance rather than through an imported singleton.

```ts
import { getLocale, getInstance } from "~/middleware/i18next";

export async function loader({ context }: Route.LoaderArgs) {
	let locale = getLocale(context);
	let i18next = getInstance(context);

	return { locale, title: i18next.t("title") };
}
```

Returning the locale from the root loader is what lets the browser agree with the server. The app component compares that value with the language on the client instance and calls changeLanguage when they differ, which is the point where a client side navigation to a route in another language gets corrected.

```tsx
export default function App({ loaderData: { locale } }: Route.ComponentProps) {
	let { i18n } = useTranslation();

	useEffect(() => {
		if (i18n.language !== locale) i18n.changeLanguage(locale);
	}, [locale, i18n]);

	return <Outlet />;
}
```

The root route also sets the lang attribute and the direction from the same instance, so a right to left locale gets the correct dir value in the rendered document.

## The client trusts the html lang attribute instead of guessing from the browser

On the browser side the language is not detected from user preferences. The client configuration uses the htmlTag detector, which reads the lang attribute the server already rendered, and caches is an empty list, so nothing is written to localStorage. The backend loadPath points at a route that serves locale JSON, with the language and the namespace interpolated into the path.

```tsx
async function main() {
	await i18next
		.use(initReactI18next)
		.use(Fetch)
		.use(I18nextBrowserLanguageDetector)
		.init({
			fallbackLng: "en",
			detection: { order: ["htmlTag"], caches: [] },
			backend: { loadPath: "/api/locales/{{lng}}/{{ns}}" },
		});
```

Hydration then happens inside a startTransition, wrapped in the i18next provider and the router.

```tsx
	startTransition(() => {
		hydrateRoot(
			document,
			<I18nextProvider i18n={i18next}>
				<StrictMode>
					<HydratedRouter />
				</StrictMode>
			</I18nextProvider>,
		);
	});
```

The consequence is that server and browser agree by construction. The server decides the language, writes it into the html element, and the client reads that value back rather than making a second decision that could land on a different locale. The trade off is that a language switch has to go through the server, since the attribute is the source of truth on the client.

## Serving locale JSON is a route you write, and the guide example stops early

Serving the locale data is not a built in feature of the library. The package expects a route at whatever path you configure in loadPath, and the README shows how to build one. The route validates the language parameter against the keys of the resources object with a zod enum, validates the namespace the same way against the keys of the resolved locale, and returns a 400 carrying the parse error when either check fails. Cache headers are applied so the JSON is cheap to serve in production.

Two caveats are worth stating plainly. The example in the documentation is cut off partway through the namespace handling, so the cache header call and the exact response shape are not shown in full, and you will have to finish that route from the described behaviour. And because the route is your code, it is your code to test, including the awkward case where a namespace exists in one locale but not in another.

This is also where the boundary with the wider i18next ecosystem is. For React Router SPA Mode the README points at react-i18next directly, with no server instance and no middleware, because there is no server render to keep in sync. remix-i18next is the framework mode answer, and the difference in approach is exactly the difference between a client library that owns the language and a framework integration that lets the server own it.

## Version lines track the router major, and the v7 line kept receiving fixes

The package is versioned against the router rather than against itself, and the supported lines are not interchangeable. The README states that Remix v2 users should install v6, React Router v7 users should install v7, and that v8 is for React Router v8, with direct links to the v6.4.1 tree and the v7.5.0 release. The published package.json carries version 8.0.0, is marked as an ES module with sideEffects false, and exposes a single entry point whose type declarations live at ./build/index.d.ts. The licence is MIT.

The repository is not archived, and the last push was on 2026-10-19. The release history shows v8.0.0 on 2026-06-25 and v7.5.0 on 2026-04-14, with v7.4.2 on 2025-10-08 sitting between them. That pattern suggests the v7 line kept receiving fixes while the v8 line was being prepared, which is good news for anyone still on React Router v7 and a reminder that this library moves with the framework.

Adopting remix-i18next means tracking the router major it targets, not just the package version. The build tooling in the repository points the same way, with a TypeScript build, an oxlint and oxfmt setup and a typedoc documentation script, so a router upgrade tends to arrive as a library upgrade too.

## Conclusion

remix-i18next fits React Router framework mode apps that want one i18next instance shared by loaders, actions and the React tree, and it earns its place in a typed codebase because the satisfies clauses and the CustomTypeOptions block turn missing translation keys into build errors. Skip it for SPA Mode, where the README points at react-i18next directly, and skip it if you are still on Remix v2 and cannot move to React Router v8, since that line is a separate major version. Before adopting, confirm the version line matches your router major and that you are willing to own the locale serving route and its namespace validation, because the library configures the backend loadPath but does not implement that route.

## FAQ

### Which remix-i18next version should I install for React Router v7?

The README states that React Router v7 users should use the v7 line, and it links the v7.5.0 release directly. v6 is for Remix v2 apps, and v8 is for React Router v8.

### How do I get the detected locale inside a loader?

Register the i18next middleware on the root route, then call getLocale(context) and getInstance(context) in the loader. The loader can return the locale to the browser and translate through the same instance, so server and client read from one configured source.

### Does remix-i18next work with React Router SPA Mode?

No. The README says SPA Mode users should use react-i18next directly, because there is no server render for the middleware to keep in sync with.

### How does the browser load translation files?

With the fetch backend and a loadPath of /api/locales/{{lng}}/{{ns}} pointing at a route you write. The client detects the language from the htmlTag detector, so it follows the lang attribute the server rendered rather than guessing from browser preferences.

### What licence is remix-i18next released under?

MIT. The package.json also declares the package as an ES module with sideEffects false, and exposes a single entry point with type declarations at ./build/index.d.ts.

## Sources

- [License: MIT](https://github.com/sergiodxa/remix-i18next/blob/main/LICENSE)
- [Project website](https://sergiodxa.github.io/remix-i18next/)
- [README](https://github.com/sergiodxa/remix-i18next/blob/main/README.md)
- [Releases](https://github.com/sergiodxa/remix-i18next/releases)
- [sergiodxa/remix-i18next on GitHub](https://github.com/sergiodxa/remix-i18next)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/sergiodxa-remix-i18next
