remix-i18next

repository·main·Indexed 20 days ago

https://github.com/sergiodxa/remix-i18next

A library for enabling server-side internationalization (i18n) in React Router (formerly Remix) applications. It provides middleware for language detection and resource management to synchronize server-side rendering with client-side hydration. Version 8.0.0 supports locale detection via URL pathnames, cookies, session storage, or databases, and integrates with i18next and react-i18next.

Tokens
8.3K
Snippets
26
Records
28
Agent score
70%

What's inside remix-i18next

  1. Hydrate i18n on the client

    main

    To prevent hydration mismatches, the client-side i18next instance must be initialized with the same language the server used. Use the <html lang> attribute as the source of truth for detection. If using a backend for on-demand loading, configure the loadPath to match your locale serving route.

    import Fetch from "i18next-fetch-backend";
    import i18next from "i18next";
    import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
    import { startTransition, StrictMode } from "react";
    import { hydrateRoot } from "react-dom/client";
    import { I18nextProvider, initReactI18next } from "react-i18next";
    import { HydratedRouter } from "react-router/dom";
    
    async function main() {
    	await i18next
    		.use(initReactI18next)
    		.use(Fetch)
    		.use(I18nextBrowserLanguageDetector)
    		.init({
    			fallbackLng: "en",
    			detection: { order: ["htmlTag"], caches: [] },
    			backend: { loadPath: "/api/locales/{{lng}}/{{ns}}" },
    		});
    
    	startTransition(() => {
    		hydrateRoot(
    			document,
    			<I18nextProvider i18n={i18next}>
    				<StrictMode>
    					<HydratedRouter />
    				</StrictMode>
    			</I18nextProvider>,
    			);
    	});
    }
    
    main().catch(console.error);
  2. Implement a custom 404 page with translations

    main

    Unmatched requests may skip the root route loader. To ensure your 404 page has access to the i18next instance and can render translated content, create a catch-all route (e.g., app/routes/$.tsx or route("*", ...)), return a 404 status in the loader, and use useTranslation in the component.

    import { useTranslation } from "react-i18next";
    import { data, href, Link } from "react-router";
    
    export async function loader() {
    	return data(null, { status: 404 });
    }
    
    export default function Component() {
    	let { t } = useTranslation("notFound");
    
    	return (
    		<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
    			<h1>{t("title")}</h1>
    			<p>{t("description")}</p>
    			<Link to={href("/")}>{t("backToHome")}</Link>
    		</div>
    	);
    }
  3. Install remix-i18next and dependencies

    main

    To set up translation state, React bindings, and language detection, install the core packages. If you want the browser to load translation files on demand (e.g., via a route), you should also install an i18next backend like i18next-fetch-backend.

    npm install remix-i18next i18next react-i18next i18next-browser-languagedetector
    
    # Optional: for on-demand loading in the browser
    npm install i18next-fetch-backend
  4. Localize Meta tags (titles and descriptions)

    main

    To localize SEO metadata, return the translated strings from your route's loader and access them via loaderData in the meta function.

    import { data } from "react-router";
    import { getInstance } from "~/middleware/i18next";
    import type { Route } from "./+types/root";
    
    export async function loader({ context }: Route.LoaderArgs) {
    	let i18next = getInstance(context);
    	return data({
    		title: i18next.t("title"),
    		description: i18next.t("description"),
    	});
    }
    
    export function meta({ loaderData }: Route.MetaArgs) {
    	return [
    		{ title: loaderData?.title },
    		{ name: "description", content: loaderData?.description }
    	];
    }
  5. Implement language switching

    main

    Depending on your locale detection strategy, use one of these two patterns:

    1. Cookie/Session based: Use a <Form method="post"> to submit the new locale. The loader/action will then detect the new value and serialize the updated cookie/session back to the client.
    2. URL based: Use <Link> components to navigate to the same route but with a different locale prefix (e.g., /en/about to /es/about).
    // Cookie-based switcher
    import { Form } from "react-router";
    export function LanguageSwitcher() {
    	return (
    		<Form method="post">
    			<button type="submit" name="lng" value="en">English</button>
    			<button type="submit" name="lng" value="es">Espanol</button>
    		</Form>
    	);
    }
    
    // URL-based switcher
    import { Link } from "react-router";
    export function LanguageSwitcher() {
    	return (
    		<nav>
    			<Link to="/en">English</Link>
    			<Link to="/es">Espanol</Link>
    		</nav>
    	);
    }
  6. Wire i18n into the React tree

    main

    To keep the client-side React state in sync with the server-detected locale, the root route should return the locale in its loader. In the component, use a useEffect hook to call i18n.changeLanguage(locale) whenever the locale changes during navigation. Use the useTranslation hook to access the current language and directionality for the <html> tag.

    // app/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 } from "./middleware/i18next";
    
    export const middleware = [i18nextMiddleware];
    
    export async function loader({ context }: Route.LoaderArgs) {
    	let locale = getLocale(context);
    	return data({ locale });
    }
    
    export function Layout({ children }: { children: React.ReactNode }) {
    	let { 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) {
    	let { i18n } = useTranslation();
    
    	useEffect(() => {
    		if (i18n.language !== locale) i18n.changeLanguage(locale);
    	}, [locale, i18n]);
    
    	return <Outlet />;
    }
  7. Persist locale using Cookies or Session Storage

    main

    To ensure user language preferences survive across requests, you can use cookie or sessionStorage in the middleware detection configuration.

    • Cookies: Use createCookie for a lightweight preference flag.
    • Session Storage: Use createCookieSessionStorage if the locale is part of a larger signed session payload.

    After the middleware detects the locale, you must manually persist it in a loader or action by writing the cookie or committing the session.

    // 1. Define storage
    import { createCookie } from "react-router";
    export const localeCookie = createCookie("lng", {
    	path: "/",
    	sameSite: "lax",
    	secure: process.env.NODE_ENV === "production",
    	httpOnly: true,
    });
    
    // 2. Configure middleware
    export const [i18nextMiddleware, getLocale, getInstance] = createI18nextMiddleware({
    	detection: {
    		supportedLanguages: ["es", "en"],
    		fallbackLanguage: "en",
    		cookie: localeCookie,
    	},
    });
    
    // 3. Persist in a loader
    export async function loader({ context }: Route.LoaderArgs) {
    	let locale = getLocale(context);
    	return data({ locale }, { headers: { "Set-Cookie": await localeCookie.serialize(locale) } });
    }
  8. Use i18n in loaders and actions

    main

    Once the middleware is registered in your root route, you can access the active locale and the i18next instance within loaders and actions using getLocale(context) and getInstance(context).

    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") };
    }
  9. Configure Server Entry for SSR

    main

    In entry.server.tsx, wrap your ServerRouter with I18nextProvider. Pass the i18next instance retrieved from the router context using getInstance(routerContext). This ensures that the server-side rendering process uses the same i18next instance configured by your middleware, keeping the language in sync during SSR.

    <I18nextProvider i18n={getInstance(routerContext)}>
    	<ServerRouter context={routerContext} url={request.url} />
    </I18nextProvider>
  10. Serve locale JSON via API route

    main

    Create a route at /api/locales/:lng/:ns to serve translation files to the client-side backend. This implementation uses zod to validate the requested language and namespace against your available resources and applies cache headers to optimize performance in production.

    import { data } from "react-router";
    import { cacheHeader } from "pretty-cache-header";
    import { z } from "zod";
    import resources from "~/locales";
    import type { Route } from "./+types/locales";
    
    export async function loader({ params }: Route.LoaderArgs) {
    	const lng = z.enum(Object.keys(resources) as Array<keyof typeof resources>).safeParse(params.lng);
    	if (lng.error) return data({ error: lng.error }, { status: 400 });
    
    	const namespaces = resources[lng.data];
    	const ns = z.enum(Object.keys(namespaces) as Array<keyof typeof namespaces>).safeParse(params.ns);
    	if (ns.error) return data({ error: ns.error }, { status: 400 });
    
    	const headers = new Headers();
    	if (process.env.NODE_ENV === "production") {
    		headers.set(
    			"Cache-Control",
    			cacheHeader({ maxAge: "5m", sMaxage: "1d", staleWhileRevalidate: "7d", staleIfError: "7d" }),
    		);
    	}
    
    	return data(namespaces[ns.data], { headers });
    }
  11. Define and structure locales

    main

    Organize your translation files by defining a default locale as the source of truth. This allows you to use TypeScript's satisfies keyword to ensure translated locales remain structurally aligned with the default one. Finally, re-export all locales from a single entry point.

    // 1. Define default locale
    // 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;
    
    // 2. Add translated locale (aligned with default)
    // app/locales/es/translation.ts
    export default {
    	title: "remix-i18next (es)",
    	description: "Un ejemplo de React Router + remix-i18next",
    } satisfies typeof import("~/locales/en/translation").default;
    
    // app/locales/es/index.ts
    import type { ResourceLanguage } from "i18next";
    import translation from "./translation";
    export default { translation } satisfies ResourceLanguage;
    
    // 3. Re-export all locales
    // app/locales/index.ts
    import type { Resource } from "i18next";
    import en from "./en";
    import es from "./es";
    export default { en, es } satisfies Resource;
  12. Detect locale from the URL pathname

    main

    You can configure the middleware to extract the locale directly from the request URL path. This is useful for apps that use a URL structure like /en/about or /es/about. The middleware will still validate the extracted value against your supportedLanguages and use the fallbackLanguage if the value is invalid.

    export const [i18nextMiddleware, getLocale, getInstance] = createI18nextMiddleware({
    	detection: {
    		supportedLanguages: ["es", "en"],
    		fallbackLanguage: "en",
    		findLocale({ request }) {
    			return new URL(request.url).pathname.split("/").at(1);
    		},
    	},
    });