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> (so <T> is SSR-correct), and seed the client store with hydrateFromServer in a client boundary.

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 SUPPORTED_LANGUAGES = ["fr", "en", "es"] as const;
const config = {
API_KEY: process.env.NEXT_PUBLIC_I18N_KEYLESS_API_KEY,
languages: { primary: "fr", 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) : "fr";

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 } 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). 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} translations={translations}>
{children}
</I18nKeylessProvider>
);
}

Now <T> / <I18nKeylessText> anywhere under [lang] is SSR-correct and flash-free.

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> + hydrateFromServer / initI18nClient in an effect.
  • Prefer <T> over imperative getTranslation() for server-rendered text.