Server-Side Rendering (SSR)
i18n-keyless works under SSR (TanStack Start, Next, Remix, Expo Router server output, …) since v2.0.0. You can server-render in your primary language with zero setup, or server-render in any supported language for SEO. This page covers both.
Requires
i18n-keyless-react >= 2.0.0.
TL;DR
storageis optional on the server — omit it and i18n-keyless uses an in-memory store. It's still required in the browser.- The server is read-only — usage analytics are never sent from the server, so SSR does not add API traffic. (On a long-lived server it's less traffic than a SPA: usage is sent once per process boot, not once per user.)
- Two SSR modes:
- Primary-language SSR (default, no extra code): the server renders your primary language; the client re-translates after hydration.
- Localized SSR (for SEO): wrap your app in
<I18nKeylessProvider>to server-render any language, indexable by search engines.
1. Primary-language SSR (default)
Call init on the server the same way 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,
// On the server, omit `storage` (it defaults to in-memory).
// In the browser, pass it as usual:
storage: typeof window === "undefined" ? undefined : window.localStorage,
});
What happens:
- 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 exactly — no hydration mismatch.
- After hydration, the client fills translations from cache and re-renders into the user's language.
That's the whole setup for primary-language SSR. Nothing else to do.
2. Localized SSR (server-render any language, for SEO)
If you want /{lang}/... or ?lang=xx URLs to actually serve translated HTML that
search engines can index, use the provider.
a. Fetch translations on the server
import { getServerTranslations } from "i18n-keyless-react";
// In your route loader / server handler:
const lang = langFromUrlOrHeader(request); // e.g. "en"
const translations = await getServerTranslations(lang); // cached per process
getServerTranslations(lang) returns the translations map for lang. It caches per
server process, so each language is fetched at most once per boot. (Requires init to
have run first. Returns {} for the primary language.)
b. Wrap your app in the provider
import { I18nKeylessProvider, T } from "i18n-keyless-react";
<I18nKeylessProvider lang={lang} translations={translations}>
<App />
{/* every <T>…</T> inside now renders in `lang` */}
</I18nKeylessProvider>;
c. Serialize for the client (flash-free hydration)
Inline the translations you fetched into the HTML, and pass the same object to the
provider on the client so the first client render matches the server:
// server: embed the data in the page
<script
id="i18n-data"
type="application/json"
dangerouslySetInnerHTML={{ __html: JSON.stringify({ lang, translations }) }}
/>;
// client: read it back and feed the same provider
const { lang, translations } = JSON.parse(document.getElementById("i18n-data").textContent);
<I18nKeylessProvider lang={lang} translations={translations}>
<App />
</I18nKeylessProvider>;
In provider mode, the language is controlled by the
langprop — drive it from the URL.setCurrentLanguageis for SPA mode (no provider).
How it works (why a provider?)
A server process handles many requests at once and the i18n-keyless store is a single
shared module. If one request switched the global language, it would leak into another
request rendering concurrently. <I18nKeylessProvider> supplies the language and
translations per render, so concurrent requests stay isolated. <T> reads the
provider first and falls back to the global store when there's no provider — which is
why SPA apps need no changes.
FAQ
Does SSR increase my API usage / cost? No. Translation-usage analytics are not sent from the server. On a long-lived server, usage is reported once per process boot (serving thousands of users), versus once per session in a SPA — so SSR is fewer calls, not more.
Do missing translations still get requested during SSR? Yes. The translate-on-miss behavior is unchanged: a key with no translation yet is still requested (once), exactly as in the browser. You never manage keys.
I'm on serverless (per-request cold starts). Anything to know?
Each cold start re-runs init. That's fine — it's read-only on the server. If you want
to be explicit, pass ssr: true to init to force read-only mode.
What runtime do I need? Node ≥ 20.10 or a modern bundler (Vite, esbuild, webpack 5, Rollup 3+).