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:

import * as rootParams from "next/root-params";

export default getRequestConfig(async ({ locale }) => {
  if (!locale) {
    try {
      const paramValue = await rootParams.locale();
      if (hasLocale(routing.locales, paramValue)) {
        locale = paramValue;
      }
    } catch {
      // rootParams not available outside route context (server actions, error boundaries)
    }
  }
  if (!locale) {
    locale = routing.defaultLocale;
  }
  return {
    locale,
    messages: await loadMessagesCached(locale),
    ...requestConfig,
  };
});

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

rootParams.locale() only works inside a route rendering context. It throws in:

  • Server actions ("use server" functions invoked outside a route)
  • Error boundaries (error.tsx)
  • Anywhere else not bound to a [locale] segment

That's what the try/catch + defaultLocale fallback in request.ts handles. If a server action needs the actual locale value, accept it as an argument or read it from the cookie — don't rely on rootParams.

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.