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 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.

tip

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

  • init via server (no storage) and client (with storage) helpers.
  • renderToPipeableStream wrapped in runWithI18nKeyless in entry.server.tsx.
  • <I18nKeylessSnapshot> rendered after the app content in root.tsx.
  • entry.client.tsx calls hydrateFromServer(...) before hydrateRoot, then initI18nClient().
  • ?lang= read from the request in entry.server.