Skip to main content

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

  • storage is 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:
    1. Primary-language SSR (default, no extra code): the server renders your primary language; the client re-translates after hydration.
    2. 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 lang prop — drive it from the URL. setCurrentLanguage is 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+).