i18n-keyless with Next.js (App Router)
The App Router (like Astro islands) has no single render hook to wrap, so
there's no runWithI18nKeyless here. You drive the component path with
<I18nKeylessProvider lang primary translations> (so <T> is SSR-correct), and seed the
client store with hydrateFromServer in a client boundary.
Requires i18n-keyless-react ≥ 3.6.1: the provider's primary prop, the "use client"
directive in the package, and a server cache that never keeps a failed fetch all landed there
(see Why primary below).
Read the SSR overview first.
Imperative getTranslation() in server output renders the primary language and
resolves to lang only after the client effect — prefer <T> for zero flash. This
guide mirrors the runnable examples/nextjs app.
1. Initialize once (server + client helpers)
// 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.NEXT_PUBLIC_I18N_KEYLESS_API_KEY,
languages: { primary: PRIMARY, supported: [...SUPPORTED_LANGUAGES] },
};
export const initI18nServer = () => init({ ...config }); // no storage
export const initI18nClient = () => init({ ...config, storage: window.localStorage });
export const normalizeLang = (value?: string | null): Lang =>
(SUPPORTED_LANGUAGES as readonly string[]).includes(value ?? "") ? (value as Lang) : PRIMARY;
2. Fetch translations in the localized layout
app/[lang]/layout.tsx is a Server Component. Fetch the language's translations and hand
them to a client boundary. Next serializes them into the RSC payload automatically — no
manual <script>:
// src/app/[lang]/layout.tsx
import type { ReactNode } from "react";
import { getServerTranslations } from "i18n-keyless-react";
import { initI18nServer, normalizeLang, SUPPORTED_LANGUAGES } from "../../i18n";
import { Providers } from "../Providers";
export function generateStaticParams() {
return SUPPORTED_LANGUAGES.map((lang) => ({ lang }));
}
export default async function LangLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ lang: string }>;
}) {
const lang = normalizeLang((await params).lang);
await initI18nServer();
const translations = await getServerTranslations(lang);
return (
<html lang={lang}>
<body>
<Providers lang={lang} translations={translations}>{children}</Providers>
</body>
</html>
);
}
3. The client boundary: Provider + hydrateFromServer
// src/app/Providers.tsx
"use client";
import { useEffect, type ReactNode } from "react";
import { I18nKeylessProvider, hydrateFromServer, type Translations } from "i18n-keyless-react";
import { initI18nClient, PRIMARY } from "../i18n";
export function Providers({
lang,
translations,
children,
}: {
lang: string;
translations: Translations;
children: ReactNode;
}) {
// <I18nKeylessProvider> makes <T> SSR-correct (React context, available while Next
// server-renders client components). `primary` is required here: the SSR layer's store
// never ran init(). hydrateFromServer + initI18nClient run in an effect: getTranslation()
// renders the PRIMARY language on the server and the first client render (no mismatch),
// then resolves to `lang` after this effect.
useEffect(() => {
hydrateFromServer({ lang: lang as never, translations });
initI18nClient();
}, [lang, translations]);
return (
<I18nKeylessProvider lang={lang as never} primary={PRIMARY} translations={translations}>
{children}
</I18nKeylessProvider>
);
}
Now <T> / <I18nKeylessText> anywhere under [lang] is SSR-correct and flash-free — in
client components and in Server Components alike. The package ships the "use client"
directive, so a Server Component imports <T> directly:
// src/app/[lang]/page.tsx — a Server Component, no "use client"
import { T } from "i18n-keyless-react";
export default function Page() {
return (
<p>
<T>Ce paragraphe est rendu par un composant serveur.</T>
</p>
);
}
Why primary: the SSR layer
Next renders a page twice on the server: the Server Components in the RSC layer, then the
client components in a second module graph (the SSR layer) to produce their HTML. Every
module-scope singleton exists twice, the SDK store included. The init() your layout called
ran in the RSC layer; the store that <I18nKeylessProvider> and <T> see in the SSR layer
is a fresh instance with the default config.
Before 3.6.1 the hooks compared lang with the store's primary language — in the SSR
layer, the default fr. A French-primary app therefore rendered the English source text under
<I18nKeylessProvider lang="fr"> (the request language looked like the source language),
while lang="de" worked by accident. With primary on the provider, the hooks read the
primary from context and never from the store. Omit it and the provider falls back to the
store's primary and warns in development when that store never ran init().
Two related behaviours of the same release:
getServerTranslations(lang)caches a language per process, but never a failed fetch: a timeout answers{}for that request and is retried on the next one. On an edge isolate that changes nothing; on a long-lived Node server it is the difference between one bad boot and a process that serves the source strings until it restarts.- Translate-on-miss runs in an effect, never on the server: the first server render of a new string is the source text until a browser has rendered it once. Seed the project before opening it to crawlers.
getTranslation() in Server ComponentsThere's no render hook to wrap in the App Router, so getTranslation() evaluated during
the server render returns the primary language and resolves to the target language only
after the client effect (a brief flash on a cold cache). Prefer <I18nKeylessText> / <T>
for server-rendered text. (Pages Router isn't covered by the example app; the same client
boundary works there, fed from getServerSideProps.)
Checklist
-
initvia server (nostorage) and client (withstorage) helpers. -
app/[lang]/layout.tsxfetchesgetServerTranslations(lang)and renders<Providers>. -
Providersis a"use client"boundary:<I18nKeylessProvider lang primary translations>hydrateFromServer/initI18nClientin an effect. Passprimary.
- Prefer
<T>over imperativegetTranslation()for server-rendered text.<T>works in Server Components too (i18n-keyless-react≥ 3.6.1).