Skip to content
Chapter 84Lesson 5

Wiring next-intl into Next.js 16

The six config files that wire next-intl v4 into the Next.js 16 App Router, turning the prior lessons' locale, catalog, and timezone discipline into running code.

For four lessons you wrote i18n calls that pointed at nothing. t('invoice.pastDue.title') named a key no engine would read; useTranslations, getTranslations, and format.dateTime(...) were call shapes you took on trust, with no resolved locale or timezone behind them. You learned the discipline as rules you could not yet run: keys and catalogs, ICU MessageFormat, the Intl.* family behind lib/format.ts, and the resolution chain that lands a Locale on every request.

This lesson supplies the engine. The keys get typed against the catalog, the resolution chain runs as real code, and the timezone you threaded by hand gets wired into the render tree once, so every formatter downstream inherits it.

The library is next-intl v4 — in 2026 barely a choice at all. It brings native Server Component support, App Router routing primitives, the ICU MessageFormat parser and Intl.* engines from earlier lessons wired to the request’s locale and timezone, build-time key typing, and a ~2KB client runtime. The alternatives each miss the App Router on some axis; nothing else on this stack clears that bar.

By the end you will have six files that a request walks through in order: negotiate a locale, load its catalog, then render translated, locale-formatted output with type-safe keys. This stack ships a single locale at launch — i18n as a discipline before it is a feature — so treat this wiring as the one-time cost that turns the second locale into a one-PR change instead of a refactor.

Six config files share a dozen exports between them. The clearest way in is to trace one GET /dashboard through all six and watch each do its single job. Scrub the diagram below to see which file is active at each step and what it hands to the next.

Step 1, proxy.ts. GET /dashboard hits the proxy first. createMiddleware(routing) reads the URL prefix, the cookie, and Accept-Language, running last lesson’s resolution chain as code. It picks a locale and rewrites the URL to its resolved form.

Step 2, i18n/routing.ts. The config createMiddleware was built from: locale list, default locale, and URL-prefix mode. One source feeds both the middleware and the navigation primitives.

Step 3, i18n/request.ts. getRequestConfig runs once per request. It takes the resolved locale, loads messages/{locale}.json, and attaches the user’s timeZone and a single now anchor.

Step 4, app/[locale]/layout.tsx. The layout calls setRequestLocale(locale), sets <html lang={locale}>, and mounts the provider that ferries messages to client components.

Step 5, Server Component. A Server Component calls useTranslations or useFormatter. The output is translated and locale-formatted, with no wiring at the call site.

Step 6, Client Component. A client component inside the provider reads the same t and format synchronously. The boundary is invisible to the translation.

Two terms anchor that walk. Step 1 is middleware , code that runs before a route renders. Steps 4 and 5 are where the locale decides whether a page can prerender or must rebuild on every request, the most error-prone rule in the lesson.

The same files as a static map. Each one-line annotation is the table of contents for the rest of the lesson, since each file gets its own section below.

  • Directoryi18n/
    • routing.ts defineRouting: locale list, default, prefix mode
    • navigation.ts createNavigation: locale-aware Link / redirect / usePathname / useRouter
    • request.ts getRequestConfig: per-request messages, timeZone, now
  • proxy.ts createMiddleware(routing): runs the resolution chain
  • Directoryapp/[locale]/
    • layout.tsx setRequestLocale + <html lang> + provider
  • Directorymessages/
    • en-US.json catalogs from the keys-and-catalogs lesson
    • fr-FR.json
    • de-DE.json
  • global.ts AppConfig augmentation: type-safe keys

One note on placement. The i18n/ directory sits at the project root beside app/, not under lib/, matching next-intl’s convention: its three files are framework wiring, not pure helpers, so they live next to the route tree they serve. Your locale facts from the last lesson, SUPPORTED_LOCALES, DEFAULT_LOCALE, and the Locale type, stay in lib/i18n.ts, and the i18n/ files import from there. One holds your domain’s locale facts, the other the framework’s plumbing around them.

i18n/routing.ts is the shared source of truth: one object that almost everything else in the wiring derives from.

i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '@/lib/i18n';
export const routing = defineRouting({
locales: SUPPORTED_LOCALES,
defaultLocale: DEFAULT_LOCALE,
localePrefix: 'as-needed',
});

Notice where locales and defaultLocale point. You declared SUPPORTED_LOCALES and DEFAULT_LOCALE in lib/i18n.ts last lesson, and routing imports them instead of restating the list. That is the single-source rule made concrete: the middleware and the navigation primitives both derive from routing, so adding a locale is one edit in lib/i18n.ts and the rest of the wiring follows.

The one real decision here is localePrefix, which controls how the locale shows up in the URL. The three modes differ in the shape of the URL, not the code:

ModeDefault-locale URLOther-locale URLWhen
'always'/en-US/dashboard/fr-FR/dashboardMulti-locale from day one; deterministic but every URL carries a prefix
'as-needed'/dashboard/fr-FR/dashboardOne market dominates; clean default URLs, prefixes only where needed
'never'/dashboard/dashboardLocale comes entirely from a cookie; no per-locale URL

We pick 'as-needed' for the single-locale launch. The launch market gets clean, prefix-free URLs, and every other locale still has an addressable URL, which the next lesson’s hreflang SEO tags need a target to point at. 'never' leaves no such target, so it is rarely the right call.

Inside app/[locale]/, the Link and helpers from next/link and next/navigation are subtly wrong: they drop the locale prefix. A French user on /fr-FR/dashboard clicks a plain <Link href="/settings"> and lands on /settings, the default-locale route, silently switching their language mid-session. The link did what you wrote; you wrote it against the wrong primitive.

next-intl’s navigation factory fixes this by producing locale-aware versions from your routing config:

i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

Each export is the locale-aware twin of a Next.js primitive: Link is an <a> that keeps the current prefix, redirect is a server or action redirect that does the same, useRouter is programmatic navigation, and getPathname builds a prefixed href without navigating. usePathname has one behavior worth flagging, which the snippet annotates:

const pathname = usePathname();

That stripped-prefix behavior is what closes the loop on the locale switcher from last lesson. You built its data half there, the Server Action that writes users.locale and the cookie; its navigation half is here. usePathname returns the current path without the prefix, and router.replace (or a <Link locale="...">) re-prefixes it under the new locale, so switching language on a deep route keeps you there instead of bouncing you home.

The rule for the whole app/[locale]/ tree: every navigation import comes from @/i18n/navigation, never next/link or next/navigation. A bare next/link import inside a localized route is a review finding, the silent-language-switch bug waiting to happen.

The proxy: where the resolution chain runs

Section titled “The proxy: where the resolution chain runs”

This is the file promised in the last lesson, the one place the resolution chain runs. It takes one of two shapes, depending on whether i18n is the only thing that happens before your routes render.

proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from '@/i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: '/((?!api|_next|_vercel|.*\\..*).*)',
};

When i18n is the only thing running before your routes, this is the whole file. createMiddleware(routing) returns a middleware, which you export as the default.

The two shapes differ only in their export, and that is where people get confused. Next.js 16’s proxy.ts accepts either a default export or a named proxy function. The i18n-only file uses export default because next-intl’s standalone setup is a single default export; the composed file uses export function proxy because a default export of an anonymous wrapper reads worse.

Order is what matters. In the composed shape, i18n negotiation and the URL rewrite run first, so every layer downstream sees a request whose URL is already resolved and prefixed: the auth gate, the security headers, and the route itself. The auth gate and the nonce-based CSP slot in right after the handleI18n(request) call, operating on the response it returns. This lesson owns the i18n layer and the seam where the others attach, not their code.

Both shapes carry the same config export. The matcher excludes API routes, framework internals, and any path with a file extension, so the proxy fires only on page routes.

This file enforces the rule the last lesson stated: negotiation happens once, here. No component, formatter, or Server Action re-reads Accept-Language. The proxy resolves the locale, and everything downstream reads the resolved value. An Accept-Language read anywhere outside proxy.ts is a finding.

i18n/request.ts is the one seam where the resolved locale, the catalog, the timezone, and a single now converge, once per request. It does three jobs.

i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
import { getUserTimeZone } from '@/lib/session';
import type { Locale } from '@/lib/i18n';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = (routing.locales.includes(requested as Locale)
? requested
: routing.defaultLocale) as Locale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: await getUserTimeZone(),
now: new Date(),
};
});

getRequestConfig runs once per request. requestLocale is the locale the proxy already resolved, so this file reads the chain’s output instead of re-running the chain.

i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
import { getUserTimeZone } from '@/lib/session';
import type { Locale } from '@/lib/i18n';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = (routing.locales.includes(requested as Locale)
? requested
: routing.defaultLocale) as Locale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: await getUserTimeZone(),
now: new Date(),
};
});

Validate and fall back: an unknown or missing locale drops to the default. It’s the same guard from the resolution chain, repeated here as the last check before a catalog loads.

i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
import { getUserTimeZone } from '@/lib/session';
import type { Locale } from '@/lib/i18n';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = (routing.locales.includes(requested as Locale)
? requested
: routing.defaultLocale) as Locale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: await getUserTimeZone(),
now: new Date(),
};
});

A dynamic import: only the active locale’s catalog ships in this response. A static import of every catalog would bundle the whole translation set into every request.

i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
import { getUserTimeZone } from '@/lib/session';
import type { Locale } from '@/lib/i18n';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = (routing.locales.includes(requested as Locale)
? requested
: routing.defaultLocale) as Locale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: await getUserTimeZone(),
now: new Date(),
};
});

The user’s timeZone, the profile column from the time-and-dates chapter, enters here and only here, so every format.dateTime downstream gets it without a manual argument. Anonymous requests fall back to 'UTC'.

i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
import { getUserTimeZone } from '@/lib/session';
import type { Locale } from '@/lib/i18n';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = (routing.locales.includes(requested as Locale)
? requested
: routing.defaultLocale) as Locale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: await getUserTimeZone(),
now: new Date(),
};
});

One per-request now anchor, the tree’s only sanctioned Date, just as the time-and-dates chapter confined a Date to a third-party boundary. format.relativeTime reads this instead of calling Date.now() itself, so “3 minutes ago” is computed once and the server render matches the client with no hydration mismatch.

1 / 1

This is where locale, catalog, timezone, and now originate, and nothing downstream re-derives any of them. The Intl-formatter rule that every date formatter needs a timeZone, and the resolution-chain rule that every render reads the resolved locale, both now hold structurally: each value is wired into the tree at this one seam rather than passed by hand at a hundred call sites.

Static rendering: setRequestLocale and generateStaticParams

Section titled “Static rendering: setRequestLocale and generateStaticParams”

This is the lesson’s most error-prone rule, and where production sites silently regress.

Next.js 16 prefers static rendering : it builds a page’s HTML once at build time and serves it from a CDN. next-intl normally reads the locale from the request headers, and reading headers forces the page to render per-request. Your authenticated app is dynamic anyway, since auth reads cookies, so that costs you nothing there. But a public marketing page that should be static and CDN-cached becomes a real, measurable regression that no one catches at code review.

The fix is a per-request locale store you opt into. You write the resolved locale into it at the top of the layout, and next-intl reads from the store instead of the headers, so the page goes back to being static.

app/[locale]/layout.tsx
import { setRequestLocale } from 'next-intl/server';
import { NextIntlClientProvider } from 'next-intl';
import { routing } from '@/i18n/routing';
import { notFound } from 'next/navigation';
import type { Locale } from '@/lib/i18n';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) notFound();
setRequestLocale(locale as Locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}

generateStaticParams tells Next.js which locales to prerender; without it, only the default locale builds at build time. It maps over routing.locales, the single source again.

app/[locale]/layout.tsx
import { setRequestLocale } from 'next-intl/server';
import { NextIntlClientProvider } from 'next-intl';
import { routing } from '@/i18n/routing';
import { notFound } from 'next/navigation';
import type { Locale } from '@/lib/i18n';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) notFound();
setRequestLocale(locale as Locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}

params is a Promise in Next.js 16, so you await it, then validate the segment and notFound() on anything unsupported. Now /xx-XX/dashboard 404s instead of rendering with a broken locale.

app/[locale]/layout.tsx
import { setRequestLocale } from 'next-intl/server';
import { NextIntlClientProvider } from 'next-intl';
import { routing } from '@/i18n/routing';
import { notFound } from 'next/navigation';
import type { Locale } from '@/lib/i18n';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) notFound();
setRequestLocale(locale as Locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}

The rule. This writes the locale to the per-request store, so downstream useTranslations and useFormatter resolve without a dynamic header read, re-enabling static rendering. Call it before any other next-intl call in the file.

app/[locale]/layout.tsx
import { setRequestLocale } from 'next-intl/server';
import { NextIntlClientProvider } from 'next-intl';
import { routing } from '@/i18n/routing';
import { notFound } from 'next/navigation';
import type { Locale } from '@/lib/i18n';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) notFound();
setRequestLocale(locale as Locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}

The accessibility and SEO hook from the last lesson, driven by the resolved param. Use the cookie instead and it mismatches the URL and breaks hydration.

app/[locale]/layout.tsx
import { setRequestLocale } from 'next-intl/server';
import { NextIntlClientProvider } from 'next-intl';
import { routing } from '@/i18n/routing';
import { notFound } from 'next/navigation';
import type { Locale } from '@/lib/i18n';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as Locale)) notFound();
setRequestLocale(locale as Locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}

The bridge that carries messages to client components, shown bare here; a later section scopes it properly. For now, just see that it wraps the tree.

1 / 1

Here is the rule, the single most common next-intl production mistake: every page.tsx and every layout.tsx under app/[locale]/ starts with setRequestLocale(locale), nested pages included, not just the root layout. Skip it in any one of them and that route silently converts to dynamic, with no error, no warning, nothing in the build log. The page that used to be CDN-cached now rebuilds on every request, and it stays invisible until someone profiles it. The defense is not vigilance at review time; it is the blanket rule applied to every file in the tree.

The static-versus-dynamic split then falls out cleanly. A public marketing page goes static: setRequestLocale, prerendered per locale, served from the CDN. The authenticated app is dynamic regardless, because auth reads cookies, but you still call setRequestLocale to keep the blanket rule and consistent locale resolution. The guardrail: never call headers() or cookies() in a public layout unless you mean to opt that page out of static rendering.

This exercise drills the one ordering that bites. The layout skeleton is fixed above the steps; put the operations in the order they must run.

Order the operations at the top of `app/[locale]/layout.tsx`. Drag the items into the correct order, then press Check.

export default async function LocaleLayout({ children, params }) {
// 1
// 2
// 3
return <html lang={locale}>{/* ...render with useTranslations... */}</html>;
}
await params to get the locale
Validate the locale, notFound() if unsupported
setRequestLocale(locale) before any translation call

Reading translations: useTranslations vs getTranslations

Section titled “Reading translations: useTranslations vs getTranslations”

You met both names in the keys-and-catalogs lesson. They speak the same dot-namespaced keys and run the same ICU engine, so the only thing to learn is when each applies. The rule is one sentence: use* inside the render tree, get* outside it. Here is the same call from three sites.

import { useTranslations } from 'next-intl';
export function PastDueBadge() {
const t = useTranslations('invoice.pastDue');
return <span>{t('title')}</span>;
}

The default for most rendering. Despite the use prefix, useTranslations is synchronous and works in Server Components, with no await and no async component.

The deciding boundary is the render tree : reach for useTranslations inside a component React renders, and getTranslations in generateMetadata, a Server Action, or a route handler.

t.rich is for a string that carries inline markup, such as a link, a <strong>, or an icon, where you cannot just interpolate a value because part of the sentence is a component.

t.rich('terms.line', {
link: (chunks) => <Link href="/terms">{chunks}</Link>,
});
// message: "By signing up, you agree to our <link>Terms</link>."

The catalog string carries a <link>…</link> tag, the call supplies the component that renders it, chunks is the text between the tags, and the result is a ReactNode. This retires the anti-pattern from the keys-and-catalogs lesson: splitting one sentence into a prefix key, a link-text key, and a suffix key, then concatenating JSX. That split froze the word order in the source language, so a translator who needs the link mid-sentence in German cannot move it; with t.rich the whole sentence is one key the translator owns, markup included. It is also why you never reach for dangerouslySetInnerHTML on translated content: t.rich gives you real components with none of the injection risk.

In the Intl formatter lesson, lib/format.ts served code outside React, such as utilities, tests, and scripts, where you threaded locale and timeZone through every call by hand. Inside the render tree you don’t: useFormatter wires both from i18n/request.ts, so you pass only the options.

import { useFormatter } from 'next-intl';
const format = useFormatter();
format.number(invoice.total, { style: 'currency', currency: invoice.currency });
format.dateTime(invoice.dueDate, { dateStyle: 'long' });
format.relativeTime(invoice.paidAt, now);

Each line is one of the three engines from the Intl formatter lesson, now auto-wired: format.number is Intl.NumberFormat, format.dateTime is Intl.DateTimeFormat, format.relativeTime is Intl.RelativeTimeFormat. The timeZone that lib/format.ts required you to pass now comes from the request config, so the prior lesson’s mandatory-timezone rule holds without a manual argument. relativeTime reads the per-request now anchor from getRequestConfig.

These are two front doors over the same Intl.* constructors, and the rule is which door for which call site: useFormatter (or its async sibling getFormatter) inside the tree, lib/format.ts outside it. getFormatter mirrors getTranslations, for generateMetadata and Server Actions, where hooks don’t apply. Shared presets live in i18n/formats.ts, covered in the project chapter.

The audit grep follows from the rule. Inside app/[locale]/, every date, number, and relative time goes through useFormatter, getFormatter, or lib/format.ts. A bare Intl.NumberFormat(...) or date.toLocaleString() in a component is a finding: it has escaped the locale and timezone wiring and formats against the runtime’s defaults.

Sending translations to the client: NextIntlClientProvider scope

Section titled “Sending translations to the client: NextIntlClientProvider scope”

This is a payload-size decision, and the one place next-intl v4’s defaults can quietly hurt you at scale.

A client component that calls useTranslations needs its messages in the client JavaScript bundle, because the server cannot reach into a client component and hand it strings at render time. NextIntlClientProvider puts them there. The trap is the default: in v4, a provider mounted with no messages prop automatically inherits every message and format from i18n/request.ts. A bare provider at the root therefore ships your entire catalog to every client, so a 10,000-key set lands in the bundle of a page that uses four strings.

<NextIntlClientProvider>{children}</NextIntlClientProvider>

No messages prop, so v4 forwards the whole catalog into the client bundle. Fine for a fifty-key demo, but a real payload problem once your catalog grows to thousands of keys the client never touches.

So the rule is: default to the smallest wrapping subtree and pick the namespaces it uses. Server Components read translations directly and never need the provider, because they run where the full catalog already lives. The provider exists for one job, ferrying the slice that client components consume.

Type-safe keys: the AppConfig augmentation

Section titled “Type-safe keys: the AppConfig augmentation”

The last file is the compile-time safety net. The keys-and-catalogs lesson made renaming a key a coordinated change across the component and every catalog, caught only by an ESLint plugin. This file makes a typo like t('invoice.greting') a build error rather than a runtime missing-key, because tsc catches it before the code runs. next-intl derives the key types from your source catalog through TypeScript module augmentation .

global.ts
import type messages from './messages/en-US.json';
import type { routing } from './i18n/routing';
declare module 'next-intl' {
interface AppConfig {
Messages: typeof messages;
Locale: (typeof routing.locales)[number];
}
}

Two registrations, two guarantees. Messages types every t(key) call against en-US.json, and because the source locale is your complete keyset, a misspelled key or a missing placeholder argument is a compile error. Locale registers the strict locale union derived from routing.locales, so useLocale() returns that narrow type instead of a bare string. A third member, Formats, joins this interface when i18n/formats.ts lands its shared presets in the project chapter.

The two things you tracked by hand are now compile-checked: the keyset and the locale union. Renaming a key and updating every catalog is caught by the type checker, not just a lint rule.

Once the six files exist, the discipline collapses into a small, mechanical audit set, for you, a reviewer, and an agent working in the codebase:

  • Every user-visible string goes through t() or t.rich. A string literal in JSX is a finding.
  • Every number, date, and relative time goes through useFormatter or getFormatter. A bare Intl.X() or .toLocaleString() is a finding.
  • Every navigation under app/[locale]/ imports from @/i18n/navigation. A next/link import there is a finding.
  • Every page.tsx and layout.tsx under app/[locale]/ starts with setRequestLocale(locale).
  • The locale is negotiated once, in proxy.ts, and never re-read downstream.

The canonical references for this wiring. The first two cover the file shape and the middleware layer; the last two are deep dives into the lesson’s two trickiest seams, the Server/Client translation boundary and the type-safe AppConfig augmentation.