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.
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.
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).
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
-
initin bothentry.client.tsxandentry.server.tsx(no serverstorage). -
handleRequestbody wrapped inrunWithI18nKeyless. -
<I18nKeylessProvider>rendered inroot.tsxfrom a loader. - Same
langsource in the entry and the root loader.