Skip to main content

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

Upgrading an existing app?

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

  • storage is 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:
    1. Primary-language SSR — default, no extra code. Server renders your primary language; client re-translates after hydration.
    2. Localized SSR — render any language on the server (for SEO), using getServerTranslations + runWithI18nKeyless (+ <I18nKeylessProvider> for client hydration).
  • Localized SSR covers both <T> (component path) and getTranslation(...) (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:

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:

PathUsed byResolves language via
Component path<I18nKeylessText> / <T> in the render tree<I18nKeylessProvider lang translations> (React context)
Function pathimperative 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 7entry.server calls renderToPipeableStream(...) inside runWithI18nKeyless. 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, and runWithI18nKeyless wrapping the whole handler for the function path. Call getTranslation() 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). Imperative getTranslation() 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.
Vite-based SSR (TanStack Start) needs ≥ 2.3.2

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 in lang (via the request scope).
  • <I18nKeylessText> / <T> resolve in lang (via the Provider, and also the request scope as a fallback).
Why both 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.

note

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.

note

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

Not compatible with TanStack Start (or any "renders outside the ALS" setup)

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:

  • getTranslation and <T> behave exactly as before when no scope/provider is present.
  • runWithI18nKeyless uses Node's AsyncLocalStorage, loaded via a guarded dynamic import, so node:async_hooks never enters browser / React Native bundles and the browser path is a no-op. (Verified: a browser-target bundle of the SPA exports builds with no node:async_hooks in 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)

ExportSignaturePurpose
getServerTranslations(lang) => Promise<Translations>Fetch a language's translations on the server, cached per process.
clearServerTranslationsCache(lang?) => voidEvict 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 } | undefinedRead 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.
I18nKeylessProviderprops { 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() => StorageIn-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 storage on the server; it defaults to in-memory.
  • To render non-primary languages, adopt §2 (getServerTranslations + runWithI18nKeyless + <I18nKeylessProvider>).