i18n-keyless with TanStack Start
TanStack Start (like any Vite-based SSR setup) splits the server entry from the SSR render
into separate module graphs / V8 realms. Versions before 2.3.2 could end up with two
AsyncLocalStorage instances — the write side and read side wouldn't see each other, and
?lang=en would render in your primary language with a hydration mismatch. Upgrade to
≥ 2.3.2 (no code change beyond the version).
Read the SSR overview, especially
the two paths. TanStack Start is where the distinction bites: the component tree
renders outside the ALS scope — only head() and route loaders run inside it. So you
need both pieces:
<I18nKeylessProvider>(fed by the root loader) → covers the component path (<I18nKeylessText>/<T>).runWithI18nKeylesswrapping the whole handler → covers the function path (getTranslation), which you call only in loaders /head().
This guide mirrors the runnable
examples/tanstack-startapp.
1. Initialize once (server + client helpers)
Keep one init config and two entry points — server omits storage, client passes it:
// src/i18n.ts
import { init, type Lang } from "i18n-keyless-react";
export const PRIMARY = "fr";
export const SUPPORTED_LANGUAGES = ["fr", "en", "es"] as const;
const config = {
API_KEY: process.env.I18N_KEYLESS_API_KEY ?? import.meta.env.VITE_I18N_KEYLESS_API_KEY,
languages: { primary: PRIMARY, supported: [...SUPPORTED_LANGUAGES] },
};
export const initI18nServer = () => init({ ...config }); // no storage → in-memory
export const initI18nClient = () => init({ ...config, storage: window.localStorage });
export function normalizeLang(value?: string | null): Lang {
return (SUPPORTED_LANGUAGES as readonly string[]).includes(value ?? "") ? (value as Lang) : PRIMARY;
}
// ?lang= is the single source of truth for the request language.
export const langFromRequest = (request: Request): Lang =>
normalizeLang(new URL(request.url).searchParams.get("lang"));
2. Wrap the whole handler in runWithI18nKeyless
A common mistake is wrapping only defaultStreamHandler. That's too deep — head() and
route loaders resolve around the render callback, so they'd fall outside the scope. Wrap
the entire fetch instead:
// src/server.ts
import { createStartHandler, defaultStreamHandler } from "@tanstack/react-start/server";
import { createServerEntry } from "@tanstack/react-start/server-entry";
import { getServerTranslations, runWithI18nKeyless } from "i18n-keyless-react";
import { initI18nServer, langFromRequest } from "./i18n";
await initI18nServer(); // once per server process, so getServerTranslations has config
const baseHandler = createStartHandler(defaultStreamHandler);
const fetch = (async (request: Request, ...rest: unknown[]) => {
const lang = langFromRequest(request);
const translations = await getServerTranslations(lang);
// Wrap the ENTIRE request so head() + loaders also run inside the ALS scope.
return runWithI18nKeyless({ lang, translations }, () => baseHandler(request, ...rest));
}) as typeof baseHandler;
export default createServerEntry({ fetch });
getTranslation(...) now resolves in lang inside loaders and head(). It does
not resolve inside a component body (the tree renders outside the scope) — that's the
Provider's job, next.
3. Provide lang + translations to the component tree
Return { lang, translations } from the root loader and render <I18nKeylessProvider>.
TanStack serializes the loader data and replays it on the client, so the same provider
mounts with the same translations on hydration — no flash:
// src/routes/__root.tsx
import { createRootRoute, Outlet } from "@tanstack/react-router";
import {
getServerTranslations,
I18nKeylessProvider,
useI18nKeyless,
type Lang,
type Translations,
} from "i18n-keyless-react";
import { normalizeLang } from "../i18n";
export const Route = createRootRoute({
// ?lang= must be validated to be readable in loaderDeps.
validateSearch: (search: Record<string, unknown>) => ({
lang: typeof search.lang === "string" ? search.lang : undefined,
}),
// Re-run the loader when ?lang= changes (URL is the single source of truth).
loaderDeps: ({ search }) => ({ lang: search.lang }),
loader: async ({ deps }): Promise<{ lang: Lang; translations: Translations }> => {
const lang = normalizeLang(deps.lang);
const translations =
typeof window === "undefined"
? await getServerTranslations(lang) // server: fetch the full map
: useI18nKeyless.getState().translations; // client nav: reuse the store
return { lang, translations };
},
component: RootComponent,
});
function RootComponent() {
const { lang, translations } = Route.useLoaderData();
return (
<I18nKeylessProvider lang={lang} translations={translations}>
<Outlet />
</I18nKeylessProvider>
);
}
The client entry just calls initI18nClient() (so background fetches and language switches
have the full set) and hydrates — no hydrateFromServer needed here, because the
function-path strings travel as resolved loader data and the Provider seeds the store on
mount:
// src/client.tsx
import { hydrateRoot } from "react-dom/client";
import { StartClient } from "@tanstack/react-start";
import { createRouter } from "./router";
import { initI18nClient } from "./i18n";
initI18nClient();
hydrateRoot(document, <StartClient router={createRouter()} />);
4. Call getTranslation() only in loaders / head()
Because the component tree renders outside the ALS, getTranslation() in a component
body returns the primary language. Call it where the scope is live — the loader — and
read the result from useLoaderData():
// src/routes/about.tsx
import { createFileRoute } from "@tanstack/react-router";
import { getTranslation } from "i18n-keyless-react";
export const Route = createFileRoute("/about")({
// FUNCTION PATH — runs inside the ALS scope (server.ts wraps the whole handler).
loader: () => ({
intro: getTranslation("Bienvenue sur la page À propos."),
// `context` disambiguates "8 heures" → "8 AM" (time) vs "8 hours" (duration).
asTime: getTranslation("8 heures", { context: "heure" }),
asDuration: getTranslation("8 heures", { context: "durée" }),
}),
component: () => {
const { intro } = Route.useLoaderData();
return <p>{intro}</p>; // ✅ reads serialized loader data, not getTranslation in render
},
});
For text in component bodies, use the component path (<I18nKeylessText> / <T>).
Gotchas
?lang= is the single source of truthThe language switcher should just navigate the URL (e.g. search={{ lang: "fr" }}), and
links should preserve it (search={(prev) => prev}). Do not add an effect syncing
currentLanguage → URL — it creates an infinite navigation loop.
translations in a wrapper<I18nKeylessProvider> seeds the store in an effect with a fresh translations reference
each run. A wrapper that subscribes to store.translations and feeds it back into the
provider re-renders forever. Read translations from loader data; subscribe to
currentLanguage only.
getUsedTranslationsSnapshot() is incompatible hereThe per-page key subset relies on keys being recorded during the render — but the body
renders outside the ALS (and routes are code-split), so those renders are never recorded
and the subset misses body keys → hydration mismatch. Serialize the full map via the
loader (getServerTranslations(lang)), not the subset.
Checklist
- i18n-keyless ≥ 2.3.2.
-
initvia server (nostorage) and client (withstorage) helpers. - The whole
fetchwrapped inrunWithI18nKeyless(not justdefaultStreamHandler). -
<I18nKeylessProvider>rendered from the root route with loader data. -
getTranslation()only in loaders /head()— never in a component body. - Full translation map serialized (not
getUsedTranslationsSnapshot()). -
?lang=read consistently in the handler and the loader.