Intl-T

Next.js

App Router integration — RSC, static rendering, request locale, namespaces

intl-t/next is the App Router surface: server components, static rendering, request-scoped locales, and hydration handled for you. Setup lives in Quick Start; URL shape and locale detection live in Routing. This page is the contract for everything in between.

i18n/translation.ts
import { createTranslation } from "intl-t/next";
import en from "./messages/en.json";
import es from "./messages/es.json";

export const { Translation, useTranslation, getTranslation, t } = createTranslation({
  locales: { en, es },
});

Use intl-t/next for translations in App Router code, intl-t/navigation for createNavigation in your proxy (it imports only request primitives, so it stays Edge-safe), and intl-t/react for the Pages Router — importing intl-t/next from Pages code pulls App Router server modules into the page bundle.

Server components

Two modes, one rule: the request locale must be resolved before a component reads messages.

await getTranslation() resolves the locale from the request (the x-locale header your proxy sets) and returns the tree already loaded:

app/page.tsx
import { getTranslation } from "@/i18n/translation";

export default async function Page() {
  const t = await getTranslation("homepage");
  return <h1>{t.title}</h1>;
}

Reading request headers is what makes the route dynamic — the await is the explicit opt-in.

With [locale] routes, tell Intl-T the locale for this render instead of reading the request:

app/[locale]/page.tsx
import { getTranslation } from "@/i18n/translation";
import { setRequestLocale } from "intl-t/next";

export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  setRequestLocale(locale);

  const t = getTranslation("homepage"); // sync — the locale is already known
  return <h1>{t.title}</h1>;
}

setRequestLocale caches the locale for the render without opting the page into dynamic rendering — SSG and PPR keep working. Call it at the top of every page and layout that renders under [locale], and everything below reads synchronously.

If repeating that call across segments bothers you, root params resolve the same locale once, for the whole app.

Loaders + static rendering: gate each segment

Next renders a page in parallel with its layout, so a layout's await cannot protect the page below it. With dynamic-imported locales, any segment whose components read messages synchronously needs one await getTranslation() at its top — after that single await, every child of that segment reads freely. One gate per segment, not per component. In serverless this matters on every cold start, not just the first request.

Root params

Next can expose the params of your root segment to every Server Component, without reading the request. Wire them once in the file that creates your translation and await getTranslation() resolves the segment locale everywhere — no per-page setRequestLocale:

i18n/translation.ts
import { createTranslation } from "intl-t/next";
import { wireRootParams } from "intl-t/root";

wireRootParams(); // picks your root param — [locale], [lang], any name

export const { Translation, useTranslation, getTranslation } = createTranslation({ locales: { en, es } });

Auto-detection prefers a param named locale and otherwise takes the only root param there is. With several non-locale root params, name yours: wireRootParams("lang"). The wired getter participates in "use cache" keys automatically.

Two conditions decide whether this works for you:

Next versionWhat to do
16.3+Nothing — root params are on by default
15.x – 16.2Enable experimental: { rootParams: true } in next.config
14.x and olderStay with setRequestLocale — the API does not exist yet

The second condition is Next's own definition of a root param: the root layout must live inside the dynamic segment (app/[locale]/layout.tsx, with nothing above it). If a layout sits above [locale], the segment is not a root param and the getter finds nothing.

Forgetting the flag is safe — the build stops with Next's own message telling you to enable it. The import itself is safe in a file your client components share: intl-t/root resolves per graph through the react-server exports condition, so the server graph gets the real getters and the client graph an inert module where the call does nothing.

Server Actions and Route Handlers keep resolving through headers, since Next does not expose root params there yet.

Server actions

The locale resolves from the action's request headers, same as dynamic pages:

app/actions.ts
"use server";
import { getTranslation } from "@/i18n/translation";

export async function greeting(name: string) {
  const t = await getTranslation("emails");
  return String(t.greeting({ name }));
}

Metadata

app/[locale]/layout.tsx
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  setRequestLocale(locale);
  const t = getTranslation("metadata");
  return t.toJSON(); // the subtree as a plain object: { title, description, ... }
}

Client components

The Translation provider is a server component that serializes the active locale's messages into the RSC payload — the client hydrates without fetching anything it already received.

app/[locale]/layout.tsx
import { Translation } from "@/i18n/translation";

export default async function Layout({ children, params }: LayoutProps) {
  const { locale } = await params;
  setRequestLocale(locale);
  return <Translation locale={locale}>{children}</Translation>;
}
components/hero.tsx
"use client";
import { useTranslation } from "@/i18n/translation";

export function Hero() {
  const t = useTranslation("homepage");
  return <h1>{t.title}</h1>;
}

Scope the payload when a route only needs a subtree — path prunes what gets serialized:

<Translation locale={locale} path="checkout">
  {children}
</Translation>

Pruning trims the serialized payload, not the type surface: client components keep reading with full paths (useTranslation("checkout.summary")).

Namespaces — one translation per domain

For large apps, split by domain instead of one giant tree: one createTranslation per namespace, each with an id. The id travels with the provider, so the client resolves the right instance even with several trees mounted.

i18n/products.ts
import { createTranslation } from "intl-t/next";
import en from "./messages/products/en.json";
import es from "./messages/products/es.json";

export const productT = createTranslation({ id: "products", locales: { en, es } });
app/[locale]/products/layout.tsx
import { productT } from "@/i18n/products";

export default async function ProductsLayout({ children, params }: LayoutProps) {
  const { locale } = await params;
  return <productT.Translation locale={locale}>{children}</productT.Translation>;
}

Nested providers inherit the active locale from the tree above them — the inner provider carries its own messages and follows the outer locale unless you pass it an explicit locale of its own:

<Translation locale={locale}>
  {/* app-wide tree */}
  <productT.Translation>{/* products tree, same locale, own messages */}</productT.Translation>
</Translation>

Each namespace serializes only its own tree, loads only its own loaders, and hydrates independently.

Nodes in JSX

A node renders directly as a child or an attribute — <h1 title={t.description}>{t.title}</h1> works as written. How that is wired is covered in React.

RSC → client props

A node is a function object, and functions cannot cross the server/client boundary as props. Send the string instead: title={String(t.description)} (or t.description.base). Rendering nodes as children inside a server component is fine — they resolve to strings before serialization.

Pages Router

Use intl-t/react for translations and the proxy for locale detection — the working recipe (provider in _app, locale from the x-locale header in getServerSideProps or getStaticProps) is in the Quick Start Pages tab. Everything on this page that isn't RSC-specific — namespaces, JSX rendering, metadata via toJSON() — works the same there.

Troubleshooting

SymptomCause → fix
Keys render as their own path ("homepage.title")locales missing or empty in createTranslation
First paint shows undefined with loadersMissing per-segment gate — await getTranslation() at the top of the segment
A client component shows the default languageReading module-level t.x directly — it is anchored to its construction locale. Read via useTranslation() or t.current
Translation did not load correctly through the ProxyCalling the un-awaited getTranslation("...") result in a dynamic RSC before the locale resolved. Use await getTranslation(...), or destructure const { t } = getTranslation(...)
404s land outside the locale layoutNext resolves unmatched routes to the root not-found.tsx. Add a catch-all [...404] segment inside [locale] that calls notFound()
wireRootParams() resolves nothingA layout sits above [locale], so it is not a root param — or the flag is missing on Next < 16.3
Hydration mismatch with two React copies (monorepos)JSX wiring is per-React-instance — make sure one react resolves, or set jsxImportSource

Continue with Dynamic Import →

On this page