Server-Side Rendering (SSR)
i18n-keyless works under SSR — TanStack Start, Next, Remix, Astro, Expo Router server
output, and any Node / modern-edge runtime — since v2.0.0. You can server-render in your
primary language with zero setup, or server-render in any supported language for SEO
(indexable ?lang=xx / /{lang}/… URLs).
v2 is a drop-in for SPAs — no code changes. If you used SSR workarounds (e.g.
ssr.noExternal, a no-op server storage), you can remove them; see
Migrating from a workaround at the bottom.
TL;DR
storageis optional on the server — omit it and i18n-keyless uses an in-memory store. Still required in the browser.- The server is read-only — usage analytics are never sent from the server, so SSR adds no API traffic. (On a long-lived server it's less than a SPA: usage is sent once per process boot, not once per user.)
- Two SSR modes:
- Primary-language SSR — default, no extra code. Server renders your primary language; client re-translates after hydration.
- Localized SSR — render any language on the server (for SEO), using
getServerTranslations+runWithI18nKeyless(+<I18nKeylessProvider>for client hydration).
- Localized SSR covers both
<T>(component path) andgetTranslation(...)(function path) — but they resolve the language differently, and how much wiring you need depends on the framework. See the two paths.
Framework guides
This page covers the framework-agnostic API. For copy-paste integration into a specific meta-framework, see:
- TanStack Start — requires i18n-keyless ≥ 2.3.2
- Remix / React Router 7 (framework mode)
- Next.js
- Astro
There are also runnable example apps — one per framework — you can clone and run offline.
The shape is always: fetch translations with getServerTranslations(lang), wrap the
server with runWithI18nKeyless, and render <I18nKeylessProvider> for the components.
But how much each piece does differs by framework — read the next section before you
wire anything up.
How the language resolves: the two paths
This is the part most people get wrong. There are two translation paths, and they resolve the language through two different mechanisms:
| Path | Used by | Resolves language via |
|---|---|---|
| Component path | <I18nKeylessText> / <T> in the render tree | <I18nKeylessProvider lang translations> (React context) |
| Function path | imperative getTranslation(key) | the ALS scope set by runWithI18nKeyless |
getTranslation() is a plain function — it cannot read React context, so it relies on
the AsyncLocalStorage (ALS) scope. <I18nKeylessText> reads context first, then the
ALS, then the store. The practical consequence depends on whether the component tree
renders inside or outside the ALS scope, which is framework-specific:
- Remix / React Router 7 —
entry.servercallsrenderToPipeableStream(...)insiderunWithI18nKeyless. The tree renders inside the ALS, so both paths work from the ALS alone (the Provider is optional). - TanStack Start — the component tree renders outside the ALS; only
head()and route loaders run inside it. So you need both:<I18nKeylessProvider>(fed via the root loader) for the component path, andrunWithI18nKeylesswrapping the whole handler for the function path. CallgetTranslation()only in loaders /head(), never in a component body. - Next.js App Router / Astro islands — there's no single render hook to wrap, so use
<I18nKeylessProvider>for the component path (<T>is SSR-correct). ImperativegetTranslation()in a server-rendered component renders the primary language and resolves to the target language only after the client effect. Prefer<T>if you need zero flash.
Before 2.3.2 the ALS instance could be duplicated across the separate module graphs / V8
realms that Vite SSR frameworks create for the server entry vs. the SSR render — the write
side (runWithI18nKeyless) and read side (getTranslation) used different ALS
instances, so ?lang=en rendered in the primary language with a hydration mismatch.
2.3.2 pins the ALS to a single globalThis slot shared across all module copies. No API
change — just bump the version. (SPA, Next.js, Remix, Astro, and Node were unaffected.)
1. Primary-language SSR (default)
Call init on the server as you do on the client — just omit storage on the server:
import { init } from "i18n-keyless-react";
await init({
languages: { primary: "fr", supported: ["fr", "en"] },
API_KEY: process.env.I18N_KEYLESS_API_KEY,
storage: typeof window === "undefined" ? undefined : window.localStorage,
});
- The server renders your primary language (
<T>returns the original text). - The client's first render is also primary, so it matches the server HTML — no hydration mismatch. After hydration the client fills from cache and re-renders into the user's language.
Nothing else to do for primary-language SSR.
2. Localized SSR (render any language on the server, for SEO)
To make /{lang}/… or ?lang=xx URLs serve translated, indexable HTML:
a. Fetch the language's translations on the server
import { getServerTranslations } from "i18n-keyless-react";
const lang = langFromUrlOrHeader(request); // e.g. "en"
const translations = await getServerTranslations(lang); // cached per process
getServerTranslations(lang) returns the translations map for lang, cached per server
process (each language fetched at most once per boot). Requires init to have run.
Returns {} for the primary language.
b. Wrap the render in runWithI18nKeyless
import { runWithI18nKeyless, I18nKeylessProvider } from "i18n-keyless-react";
import { renderToString } from "react-dom/server"; // or your framework's renderer
const html = await runWithI18nKeyless({ lang, translations }, () =>
renderToString(
<I18nKeylessProvider lang={lang} translations={translations}>
<App />
</I18nKeylessProvider>
)
);
This is the whole server step. Inside it:
getTranslation(...)calls resolve inlang(via the request scope).<I18nKeylessText>/<T>resolve inlang(via the Provider, and also the request scope as a fallback).
runWithI18nKeyless and the Provider?<T> is a component and reads the Provider via React context. getTranslation(...) is a
plain function that can't see React context, so it reads the request scope set by
runWithI18nKeyless (AsyncLocalStorage). Wrapping the render in runWithI18nKeyless
covers both — the Provider is what makes client hydration flash-free (next step).
c. Hydrate flash-free on the client
Serialize the translations you fetched into the HTML, and pass the same object to
<I18nKeylessProvider> on the client so the first client render matches the server:
// server: embed the data
<script
id="i18n-data"
type="application/json"
dangerouslySetInnerHTML={{ __html: JSON.stringify({ lang, translations }) }}
/>;
// client: read it back and feed the provider
const { lang, translations } = JSON.parse(
document.getElementById("i18n-data").textContent
);
<I18nKeylessProvider lang={lang} translations={translations}>
<App />
</I18nKeylessProvider>;
On the client you don't need runWithI18nKeyless. The <I18nKeylessProvider> covers the
<T> / <I18nKeylessText> component form — it reads from React context, so it's
correct on the first client render.
In localized SSR the language is the lang you pass (drive it from the URL).
setCurrentLanguage is for SPA mode.
d. Seed getTranslation synchronously (function form) — ≥ 2.2.0
If any of your pages render text with the getTranslation(key) function in render
(not just the <I18nKeylessText> component), you need one more step. getTranslation is
a plain function — it can't read React context, so on the client it reads the store. The
Provider seeds the store on mount, which is after the first render — so on a
cold cache the first paint shows the primary language and React then re-renders into the
right one (mismatch + visible blink).
Fix it by seeding the store synchronously, before hydrateRoot, from the snapshot the
server embedded. Read the active scope on the server with getRequestScope():
// server — embed the snapshot from inside the scoped render
const html = await runWithI18nKeyless({ lang, translations }, () => {
const body = renderToString(
<I18nKeylessProvider lang={lang} translations={translations}>
<App />
</I18nKeylessProvider>
);
const snapshot = JSON.stringify(getRequestScope()); // { lang, translations }
return `${body}<script id="i18n-keyless" type="application/json">${snapshot}</script>`;
});
// client — seed synchronously, then init, then hydrate
import { hydrateFromServer, init } from "i18n-keyless-react";
// 1. Seed the store from the embedded snapshot — BEFORE the first render.
const el = document.getElementById("i18n-keyless");
if (el) hydrateFromServer(JSON.parse(el.textContent)); // { lang, translations }
// 2. init as usual — its async hydrate treats the seed as authoritative and won't
// reset to the primary language on a cold cache.
init({
languages: { primary: "fr", supported: ["fr", "en"] },
API_KEY,
storage: window.localStorage,
});
// 3. Hydrate.
hydrateRoot(document, <App />);
Now getTranslation returns the correct language on the very first client render — no
mismatch, no blink.
hydrateFromServer is SSR-only and opt-in. SPA / non-SSR apps never call it and are
unaffected. Call it once, synchronously, before hydrateRoot. It's a no-op without
snapshot.lang.
e. Large translation sets: per-page snapshot — ≥ 2.3.0
By default the snapshot embeds the full language set into every page's HTML. For a small or medium set (≲ tens of KB), leave it as is — it's simplest and doubles as a warm cache so client navigation is instant. For a large set (thousands of keys), embedding all of it in every page is wasteful. Instead embed only the keys the page actually rendered — a one-line change at your serialization site:
- const snapshot = JSON.stringify(getRequestScope()); // full language set
+ const snapshot = JSON.stringify(getUsedTranslationsSnapshot()); // only keys this page used
During the server render, every getTranslation(...) call and every <I18nKeylessText>
records the key it touched into a per-request Set (a plain Set.add — no store write,
no re-render). getUsedTranslationsSnapshot() returns just those keys, intersected with
the keys actually available:
import {
runWithI18nKeyless,
getServerTranslations,
getUsedTranslationsSnapshot,
I18nKeylessProvider,
} from "i18n-keyless-react";
const translations = await getServerTranslations(lang); // full set (cached per process)
const html = await runWithI18nKeyless({ lang, translations }, () => {
const body = renderToString(
<I18nKeylessProvider lang={lang} translations={translations}>
<App />
</I18nKeylessProvider>
);
// Only the keys this page rendered — much smaller than the full set.
const snapshot = JSON.stringify(getUsedTranslationsSnapshot());
return `${body}<script id="i18n-keyless" type="application/json">${snapshot}</script>`;
});
The full set stays in scope, so any key still resolves correctly mid-render — only the serialized payload is narrowed. Concurrent requests are fully isolated (each render has its own set).
Client-navigation invariant — keep calling init(). The per-page subset seeds only
the first paint via hydrateFromServer(subset). init() (which you already call on the
client) fetches the full language set in the background and caches it, so client-side
navigation has every key:
hydrateFromServer(subset)→ instant, correct first paint, no blink.init()'s background fetch → full set for subsequent browsing.
Until that background fetch lands, a key not in the subset resolves via translate-on-miss (on a warm cache it's already there).
| Language set | Serialize | Why |
|---|---|---|
| Small / medium (≲ tens of KB) | getRequestScope() (full) | Simplest; navigation instant; doubles as a warm cache. |
| Large (thousands of keys) | getUsedTranslationsSnapshot() (subset) | Small inline HTML; full set arrives via init()'s background fetch. |
Measure before optimizing: JSON.stringify(getRequestScope().translations).length.
getUsedTranslationsSnapshot() works by recording each key as it's rendered — which only
happens when the component tree renders inside the ALS scope. In TanStack Start (and
any framework whose body renders outside the ALS, or that uses code-split routes),
recordUsedKey never sees those component renders, so the subset misses the body's keys →
hydration mismatch. Serialize the full map via the loader there (use getRequestScope(),
not the subset). The subset is safe for the render-inside-ALS pattern (Remix / direct
renderToString).
Will this slow down or break my SPA / mobile app?
No. The SSR additions are inert in the browser:
getTranslationand<T>behave exactly as before when no scope/provider is present.runWithI18nKeylessuses Node'sAsyncLocalStorage, loaded via a guarded dynamic import, sonode:async_hooksnever enters browser / React Native bundles and the browser path is a no-op. (Verified: a browser-target bundle of the SPA exports builds with nonode:async_hooksin the output.)
FAQ
Does SSR increase my API usage / cost? No. Usage analytics are not sent from the server. On a long-lived server, usage is reported once per process boot (serving thousands of users) vs. once per session in a SPA — so SSR is fewer calls, not more.
Do missing translations still get requested during SSR? Yes — translate-on-miss is unchanged. A key with no translation yet is still requested (once). You never manage keys.
My pages render text with getTranslation(...), not <T>. Does SSR work?
Yes. On the server, wrap the render in runWithI18nKeyless (step 2b) — no per-call-site
changes. On the client, also seed the store synchronously with hydrateFromServer
before hydrateRoot (step 2d, ≥ 2.2.0) so the first client render is correct with no
blink.
Does calling getTranslation during render trigger React's "Cannot update a component
while rendering a different component" warning?
No (since ≥ 2.2.0). getTranslation defers its usage-analytics write to a
queueMicrotask, so there are zero synchronous store writes during render. The server
records nothing at all.
I'm on serverless (per-request cold starts). Anything to know?
Each cold start re-runs init; that's fine — the server is read-only. Pass ssr: true
to init to force read-only mode explicitly if you ever run with a window present.
What runtime do I need?
Node ≥ 20.10 or a modern bundler (Vite, esbuild, webpack 5, Rollup 3+).
runWithI18nKeyless needs AsyncLocalStorage (Node and most edge runtimes; Cloudflare
Workers needs a flag — where unavailable, use <I18nKeylessProvider> for <T> and the
scope degrades to a no-op).
API reference (SSR)
| Export | Signature | Purpose |
|---|---|---|
getServerTranslations | (lang) => Promise<Translations> | Fetch a language's translations on the server, cached per process. |
clearServerTranslationsCache | (lang?) => void | Evict one/all languages from that cache (e.g. after publishing new translations). |
runWithI18nKeyless | (scope, fn) => Promise<R> | Run a server render with { lang, translations } active so getTranslation and <T> resolve in lang. |
getRequestScope | () => { lang, translations } | undefined | Read the active request scope (advanced) — serialize it for the client (full set). |
getUsedTranslationsSnapshot | () => { lang, translations } | undefined | ≥ 2.3.0. Like getRequestScope but translations is narrowed to only the keys this render touched — for large sets. Render-inside-ALS only (not TanStack Start). undefined outside a runWithI18nKeyless render. |
I18nKeylessProvider | props { lang, translations, children } | Per-request context for <T>; seeds the store on the client for flash-free hydration. |
hydrateFromServer | ({ lang, translations }) => void | ≥ 2.2.0. Client-only: seed the store synchronously before hydrateRoot so the getTranslation function form is correct on first render. No-op without lang. |
createMemoryStorage | () => Storage | In-memory storage adapter (server default; exported for explicit use). |
init({ …, ssr? }) | — | storage optional on the server; ssr: true forces read-only mode. |
Migrating from a workaround
If you previously made i18n-keyless work under SSR by hand:
- Remove
ssr: { noExternal: ['i18n-keyless-*'] }from your bundler config — v2's build is valid native Node ESM and externalizes cleanly. - Remove any no-op server storage — omit
storageon the server; it defaults to in-memory. - To render non-primary languages, adopt §2 (
getServerTranslations+runWithI18nKeyless+<I18nKeylessProvider>).