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 the tree inside runWithI18nKeyless in app/entry.server.tsx. That makes it
the easy case: both paths resolve from the ALS, so you don't need
<I18nKeylessProvider> at all. Instead you serialize the used translations into the HTML
and seed the client store synchronously with hydrateFromServer.
Read the SSR overview first. On the
server, <I18nKeylessText> and getTranslation both read the ALS scope set by
runWithI18nKeyless — no Provider needed. The Provider is an option, but the example below
(and the runnable examples/remix-rr7) uses the lighter snapshot +
hydrateFromServer approach.
1. Initialize once (server + client helpers)
// app/i18n.ts
import { init, type Lang } from "i18n-keyless-react";
export const SUPPORTED_LANGUAGES = ["fr", "en", "es"] as const;
const config = {
API_KEY: process.env.I18N_KEYLESS_API_KEY,
languages: { primary: "fr", supported: [...SUPPORTED_LANGUAGES] },
};
export const initI18nServer = () => init({ ...config }); // no storage
export const initI18nClient = () => init({ ...config, storage: window.localStorage });
export const langFromRequest = (request: Request): Lang => {
const lang = new URL(request.url).searchParams.get("lang");
return (SUPPORTED_LANGUAGES as readonly string[]).includes(lang ?? "") ? (lang as Lang) : "fr";
};
2. Wrap the render in runWithI18nKeyless
Fetch the language's translations, then run the existing renderToPipeableStream(...)
inside the scope. The only additions to a stock Remix entry are the initI18nServer,
langFromRequest, getServerTranslations, and runWithI18nKeyless lines:
// app/entry.server.tsx
import { PassThrough } from "node:stream";
import type { EntryContext } from "react-router";
import { ServerRouter } from "react-router";
import { renderToPipeableStream } from "react-dom/server";
import { createReadableStreamFromReadable } from "@react-router/node";
import { getServerTranslations, runWithI18nKeyless } from "i18n-keyless-react";
import { initI18nServer, langFromRequest } from "./i18n";
await initI18nServer(); // once per server process
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
) {
const lang = langFromRequest(request);
return getServerTranslations(lang).then((translations) =>
runWithI18nKeyless({ lang, translations }, () =>
new Promise<Response>((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) {
if (shellRendered) console.error(error);
},
},
);
setTimeout(abort, 10_000);
}),
),
);
}
Inside this scope, <I18nKeylessText> and getTranslation(...) both resolve in lang.
3. Serialize the snapshot into the HTML
The tree renders inside the ALS, so each render records its keys — which means you can use
the per-page subset here (getUsedTranslationsSnapshot()). Render a tiny component
after the page content in app/root.tsx:
// app/components/I18nKeylessSnapshot.tsx
import { getUsedTranslationsSnapshot } from "i18n-keyless-react";
// Server: read the used-keys snapshot from the ALS scope and embed it.
// Client (hydration): reproduce the same JSON from the already-rendered <script> → no mismatch.
export function I18nKeylessSnapshot() {
const fromScope = getUsedTranslationsSnapshot();
const json = fromScope
? JSON.stringify(fromScope)
: typeof document !== "undefined"
? document.getElementById("i18n-keyless")?.textContent
: null;
if (!json) return null;
return <script id="i18n-keyless" type="application/json" dangerouslySetInnerHTML={{ __html: json }} />;
}
// app/root.tsx — render it AFTER the app content
import { Outlet, Scripts } from "react-router";
import { I18nKeylessSnapshot } from "./components/I18nKeylessSnapshot";
// …
<body>
<Outlet />
<I18nKeylessSnapshot />
<Scripts />
</body>
4. Seed the client synchronously, before hydration
In app/entry.client.tsx, read the #i18n-keyless snapshot and call hydrateFromServer
before hydrateRoot, so the function path is correct on the first client render (no
blink). Then initI18nClient() loads the full set in the background for navigation:
// app/entry.client.tsx
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { hydrateFromServer } from "i18n-keyless-react";
import { initI18nClient } from "./i18n";
const snapshotEl = document.getElementById("i18n-keyless");
if (snapshotEl?.textContent) {
hydrateFromServer(JSON.parse(snapshotEl.textContent)); // BEFORE hydration → no blink
}
initI18nClient();
startTransition(() => {
hydrateRoot(document, <StrictMode><HydratedRouter /></StrictMode>);
});
Checklist
-
initvia server (nostorage) and client (withstorage) helpers. -
renderToPipeableStreamwrapped inrunWithI18nKeylessinentry.server.tsx. -
<I18nKeylessSnapshot>rendered after the app content inroot.tsx. -
entry.client.tsxcallshydrateFromServer(...)beforehydrateRoot, theninitI18nClient(). -
?lang=read from the request inentry.server.