Skip to main content

i18n-keyless with Remix (and React Router 7 framework mode)

Remix — and React Router 7 in framework (SSR) mode, which shares the same entry files — renders on the server in app/entry.server.tsx via handleRequest. That's your wrap point.

tip

Read the SSR overview first. This guide assumes you know what getServerTranslations, runWithI18nKeyless, and <I18nKeylessProvider> do.

1. Initialize once

In app/entry.client.tsx and app/entry.server.tsx, call init (omit storage on the server):

import { init } from "i18n-keyless-react";

await init({
languages: { primary: "en", supported: ["en", "fr", "de"] },
API_KEY: process.env.I18N_KEYLESS_API_KEY,
storage: typeof window === "undefined" ? undefined : window.localStorage,
});

For primary-language SSR, you're done. The rest is for localized, indexable HTML.

2. Wrap handleRequest in runWithI18nKeyless

A typical app/entry.server.tsx calls renderToPipeableStream(<ServerRouter … />). Wrap that whole render. Fetch the language's translations first, then run the existing render inside the scope:

import { PassThrough } from "node:stream";
import { renderToPipeableStream } from "react-dom/server";
import { ServerRouter, type EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { getServerTranslations, runWithI18nKeyless } from "i18n-keyless-react";

function langFromRequest(request: Request) {
const url = new URL(request.url);
return url.pathname.split("/")[1] || "en"; // adapt to your routing
}

export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
) {
const lang = langFromRequest(request);
const translations = await getServerTranslations(lang);

return runWithI18nKeyless({ lang, translations }, () =>
new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(createReadableStreamFromReadable(body), {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError: reject,
onError(error) {
responseStatusCode = 500;
if (shellRendered) console.error(error);
},
},
);
setTimeout(abort, 10_000);
}),
);
}

The only additions to the stock Remix entry are the two getServerTranslations / langFromRequest lines and the runWithI18nKeyless(...) wrapper around the existing Promise. Everything inside renders in lang.

3. Provide lang + translations from the root loader

Expose the data through app/root.tsx's loader, then wrap the layout in <I18nKeylessProvider>. Remix serializes the loader data and replays it on the client, so the same provider mounts with the same translations on hydration — flash-free:

import { Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router";
import { getServerTranslations, I18nKeylessProvider } from "i18n-keyless-react";

export async function loader({ request }: LoaderFunctionArgs) {
const lang = new URL(request.url).pathname.split("/")[1] || "en";
const translations = await getServerTranslations(lang);
return { lang, translations };
}

export default function App() {
const { lang, translations } = useLoaderData<typeof loader>();
return (
<I18nKeylessProvider lang={lang} translations={translations}>
<Outlet />
</I18nKeylessProvider>
);
}

No manual <script id="i18n-data"> needed — the root loader is the serialization channel.

Using the getTranslation(key) function form?

The Provider covers the <T> / <I18nKeylessText> component form. If you also render text with the getTranslation function in render, seed the store synchronously with hydrateFromServer({ lang, translations }) in app/entry.client.tsx before hydrateRoot — the values are available on window.__remixContext (or the React Router hydration data). See Seed getTranslation synchronously (≥ 2.2.0).

note

Derive lang the same way in entry.server.tsx and root.tsx so the request scope (for getTranslation) and the Provider (for <T>) agree.

Checklist

  • init in both entry.client.tsx and entry.server.tsx (no server storage).
  • handleRequest body wrapped in runWithI18nKeyless.
  • <I18nKeylessProvider> rendered in root.tsx from a loader.
  • Same lang source in the entry and the root loader.