Locale resolution in parallel routes
How rootParams replaces setRequestLocale, and why slots no longer need per-slot locale wiring
TL;DR
The web and splash apps do not call setRequestLocale anywhere — including in parallel route slots. Locale is resolved automatically from the [locale] route segment via next/root-params, integrated once per app in i18n/request.ts.
If you're reading older docs or older slot files that pass params down just to call setRequestLocale(locale), that pattern is obsolete. Remove it.
Why the old pattern existed
With cacheComponents: true (PPR), Next.js pre-renders the static shell of every route. When next-intl needed the locale in a server component, it called getServerLocale() internally. If setRequestLocale hadn't been called in that rendering context, next-intl fell back to headers() — a dynamic API that broke pre-rendering and triggered the "Blocking Route" error.
Parallel route slots (@headerLeft, @headerCenter, etc.) each get their own rendering context, so a setRequestLocale call in the parent layout did NOT propagate. Every slot whose tree touched a next-intl server API had to call it independently.
What changed
apps/web/i18n/request.ts now resolves locale via next/root-params:
// apps/web/i18n/request.ts (splash is identical)
import { locale as localeRootParam } from "next/root-params";
import { notFound } from "next/navigation";
export default getRequestConfig(async ({ locale }) => {
if (!locale) {
const paramValue = await localeRootParam();
if (hasLocale(routing.locales, paramValue)) {
locale = paramValue;
} else {
notFound();
}
}
return {
locale,
messages: await loadMessages(locale),
...buildRequestConfig(timeZoneForLocale(locale)),
};
});Because the locale comes from the route segment (a build-time/route-time value, not a request header), it's available in every rendering context — parent layouts, parallel slots, cached components, nested server components — without any explicit wiring.
The locale value also becomes part of the cache key automatically, so "use cache" components Just Work.
What this means in practice
In server components and pages:
// ✅ Works everywhere — slots, layouts, cached components, async pages
const t = await getTranslations("namespace");
// ✅ Only call this if you need the locale value itself
const locale = await getLocale();Slot files no longer accept params just for locale:
// ❌ Old pattern — remove this
export default function SlotDefault({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = use(params);
setRequestLocale(locale);
return <MyComponent />;
}
// ✅ New pattern
export default function SlotDefault() {
return <MyComponent />;
}The next-intl <Link> from @repo/internationalization/navigation, getTranslations(), and getLocale() all resolve through rootParams now — no headers() fallback, no blocking-route error.
Where rootParams is NOT available
locale() from next/root-params only works while a ROUTE is rendering. It throws in:
- Server Actions (
"use server") - Route Handlers
- Client Components
- Anywhere else not bound to a
[locale]segment
There is no try/catch fallback in request.ts — an unresolvable locale in a route context is a 404, not a default. A bare getTranslations() inside a Server Action hits i18n/request.ts, calls the root param, and dies with "locale() was used inside a Server Action".
So the locale is passed explicitly at those two boundaries:
// Server Action: the client sends its locale with the input.
// Typed `Locale` and REQUIRED, so the typechecker catches a missing call site.
"use server";
import type { Locale } from "@repo/internationalization/routing";
import { getTranslations } from "@repo/internationalization/server";
export async function myAction(input: MyInput, locale: Locale) {
const t = await getTranslations({ locale, namespace: "my.namespace" });
}On the client, read it with useLocale() and pass it in. Route Handlers take ?locale= and validate it with coerceLocale from routing.ts. Do not read it from a cookie.
Other apps
As of next-intl 4.13.5, setRequestLocale is deprecated upstream — it still works, it just warns.
Admin never used it: it has no i18n wiring at all, so there is nothing to migrate.
Splash is migrated. It was not the one-line change it looked like. Root params are only the dynamic segments that appear before the root layout, and splash had its <html>/<body> root layout at app/layout.tsx with [locale] nested underneath — so [locale] was not a root param and rootParams.locale() would never have resolved. The migration collapsed the root layout into app/[locale]/layout.tsx (the shape web already had), deleted app/layout.tsx, and gave splash its own rootParams-based i18n/request.ts instead of re-exporting the shared requestLocale config. The <head> tags moved to the viewport export and other: { google: "notranslate" } in metadata.
Brand is migrated too, by the other route. It has no [locale] segment at all, so there was nothing to promote above the root layout and rootParams was never an option. Instead brand pins the locale in its own i18n/request.ts:
// apps/brand/i18n/request.ts
export default getRequestConfig(async () => ({
locale: defaultLocale,
messages: await loadMessages(defaultLocale),
...requestConfig,
}));That is the whole fix. Because the config never consults requestLocale, next-intl never falls back to headers() — which is the only thing setRequestLocale was ever protecting against. The 13 calls were then deleted outright.
setRequestLocale is no longer re-exported from @repo/internationalization/server. That is deliberate: nothing in the repo needs it, and dropping it from the surface is what stops it coming back.
If you migrate another app, check where its root layout lives first. An app/[locale]/layout.tsx alone is not enough; nothing may sit above it. If the app is single-locale, skip rootParams entirely and pin the locale like brand does.
Reference
Aurora Scharff — Implementing Next.js 16 use cache with next-intl — the article we followed for the rootParams integration.