Skip to main content

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

tip

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.
Imperative getTranslation() in Server Components

There'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

  • init via server (no storage) and client (with storage) helpers.
  • app/[lang]/layout.tsx fetches getServerTranslations(lang) and renders <Providers>.
  • Providers is a "use client" boundary: <I18nKeylessProvider lang primary translations>
    • hydrateFromServer / initI18nClient in an effect. Pass primary.
  • Prefer <T> over imperative getTranslation() for server-rendered text. <T> works in Server Components too (i18n-keyless-react ≥ 3.6.1).