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 locale, catalog, and timezone discipline of the prior lessons into running code.
For four lessons you have been writing code that pointed at nothing. t('invoice.pastDue.title') named a key, but no engine read the catalog. useTranslations and getTranslations were names you took on trust. format.dateTime(...) promised a locale-aware render with the user’s timezone baked in, but the request had no resolved locale and the formatter had no timezone to bake in. You learned the discipline as a set of rules with call shapes you could not yet run: keys and catalogs, ICU MessageFormat, the Intl.* family behind lib/format.ts, and the five-input resolution chain that lands a Locale on every request.
This lesson is where the engine arrives. Every promise from the last four lessons becomes a concrete file you can point to. The keys you wrote get typed against the catalog. The resolution chain you described as a priority list runs as actual code. The timezone you threaded by hand through lib/format.ts gets wired into the render tree once, so every formatter downstream gets it for free.
The choice of library is easy, because at this point in 2026 it is barely a choice at all. The library is next-intl v4. It has native Server Component support, routing primitives shaped for the App Router, the ICU MessageFormat parser from the ICU lesson running unchanged, the Intl.* engines from the Intl formatter lesson wrapped and wired to the request’s locale and timezone, build-time key typing, and a client runtime around 2KB. The alternatives each fall short on one axis. react-intl (FormatJS) has full ICU but no App-Router routing primitive, so you would hand-roll the locale-prefix layer. next-i18next is shaped for the old Pages Router. Lingui adds a heavier compile step. What next-intl gives you and the others do not is App Router support, Server Components, and minimal drift from the framework, and on this stack nothing else clears that bar.
By the end you will have built six files. A request walks through them in order: negotiate a locale, load that locale’s catalog, then render translated and locale-formatted output with type-safe keys. This stack ships a single locale at launch, which is the chapter’s whole thesis: i18n as a discipline before it is a feature. So think of this wiring as the one-time cost that turns the second locale into a one-PR change instead of a refactor.
The six files, one request
Section titled “The six files, one request”Six config files with a dozen exports between them is where most people meet next-intl and freeze. The way through is to stop seeing six files and start seeing one request walking through them. Every file does exactly one job in that walk, and once you have the walk, each later section of this lesson slots into a place you already understand.
The following diagram traces a single GET /dashboard through the wiring. Scrub through it and watch which file is active at each step and what it hands to the next.
Step 1, proxy.ts. The request GET /dashboard hits the proxy first. createMiddleware(routing) reads the URL prefix, the cookie, and Accept-Language, the resolution chain from the last lesson now running as code. It picks a locale and rewrites the URL to its resolved form.
Step 2, i18n/routing.ts. The config that createMiddleware was built from: the locale list, the default locale, and the 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 extra 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 pieces of vocabulary anchor that walk. The first box is middleware , code that runs before a route renders, which in Next.js 16 lives in a file named proxy.ts. Steps 4 and 5 are where the locale becomes the difference between a page that can prerender , built once at build time and served from cache, and one that rebuilds on every request. Hold onto that distinction: it becomes the most error-prone rule in the lesson.
Here is the same set of files as a static map. The one-line annotations double as the table of contents for the rest of this 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
- routing.ts
- proxy.ts
createMiddleware(routing): runs the resolution chain Directoryapp/[locale]/
- layout.tsx
setRequestLocale+<html lang>+ provider
- layout.tsx
Directorymessages/
- en-US.json catalogs from the keys-and-catalogs lesson
- fr-FR.json
- de-DE.json
- global.ts
AppConfigaugmentation: type-safe keys
One note on where these live. The i18n/ directory sits at the project root, beside app/, not under lib/. That matches next-intl’s current convention: the three files inside it are framework wiring rather than pure helpers, so they earn a top-level home next to the route tree they serve. The locale primitives you built in the last lesson, SUPPORTED_LOCALES, DEFAULT_LOCALE, and the Locale type, stay in lib/i18n.ts, and the i18n/ files import from there. That separation matters: one holds your domain’s locale facts, the other holds the framework’s plumbing around them.
Routing config: defineRouting
Section titled “Routing config: defineRouting”i18n/routing.ts is the shared source of truth. It is one object, and almost everything else in the wiring derives from it.
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 what locales and defaultLocale point at. You declared SUPPORTED_LOCALES and DEFAULT_LOCALE in lib/i18n.ts in the last lesson: an as const array and the Locale type derived from it. routing consumes them rather than restating the list. This is the single-source rule made concrete. The supported locales are declared once, routing imports them, and both the middleware and the navigation primitives derive from routing. Adding a locale is one edit in lib/i18n.ts, and the rest of the wiring follows.
The one real decision in this file is localePrefix, which controls how the locale shows up in the URL. There are three modes, and the difference is the shape of the URL, not the shape of the code:
| Mode | Default-locale URL | Other-locale URL | When |
| --- | --- | --- | --- |
| 'always' | /en-US/dashboard | /fr-FR/dashboard | Multi-locale from day one; deterministic but every URL carries a prefix |
| 'as-needed' | /dashboard | /fr-FR/dashboard | One market dominates; clean default URLs, prefixes only where needed |
| 'never' | /dashboard | /dashboard | Locale comes entirely from a cookie; no per-locale URL |
We pick 'as-needed', and the reason is the single-locale-launch thesis. The market you launch in gets clean, prefix-free URLs, while every other locale still has a real, addressable URL, which you will need for the hreflang SEO surface in the next lesson. 'never' forfeits that: with no per-locale URL there is nothing for a search engine to point an hreflang tag at, so it is rarely the right call.
Locale-aware navigation: createNavigation
Section titled “Locale-aware navigation: createNavigation”Inside app/[locale]/, the Link and navigation helpers you reach for from next/link and next/navigation are subtly wrong. A plain <Link href="/settings"> from next/link drops the locale prefix. A French user reading on /fr-FR/dashboard clicks it and lands on /settings, the default-locale route, silently switching their language mid-session. The link did exactly what you wrote; you just wrote it against the wrong primitive.
next-intl’s navigation factory fixes this by producing locale-aware versions of all of them from your routing config:
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 locale prefix. redirect is a server or action redirect that does the same. useRouter is programmatic navigation. getPathname builds a prefixed href without navigating. And usePathname has one behavior worth flagging in place, which the following snippet annotates:
const pathname = usePathname();That stripped-prefix behavior is exactly what the locale switcher from the last lesson needs. You already built the switcher’s data half there: it called a Server Action to write 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 on that route instead of bouncing you home. We are not rebuilding the switcher, which owns its UI; we are only closing the loop on which primitive moves the user.
The rule for the whole app/[locale]/ tree is that 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, because it is the silent-language-switch bug waiting to happen.
The proxy: running the resolution chain
Section titled “The proxy: running the resolution chain”This is the file you were promised in the last lesson, the one place the resolution chain actually runs. There are two shapes for it, and which one you write depends on whether i18n is the only thing happening before your routes render.
import createMiddleware from 'next-intl/middleware';import { routing } from '@/i18n/routing';
export default createMiddleware(routing);
export const config = { matcher: '/((?!api|_next|_vercel|.*\\..*).*)',};If i18n is the only thing running before your routes, this is the whole file. createMiddleware(routing) returns a middleware, and you export it as the default. The matcher keeps it off API routes, framework internals, and any path with a file extension, so it runs only on page routes.
import createMiddleware from 'next-intl/middleware';import { routing } from '@/i18n/routing';import type { NextRequest } from 'next/server';
const handleI18n = createMiddleware(routing);
export function proxy(request: NextRequest) { const response = handleI18n(request); // auth gate and security headers layer onto `response` return response;}
export const config = { matcher: '/((?!api|_next|_vercel|.*\\..*).*)',};Real apps run i18n and an auth gate and CSP headers in one proxy. next-intl’s middleware is just a function you call. Wrap it in a named proxy function, let i18n run first, and layer the rest onto the response it returns.
The two shapes differ in their export, and that trips people up, so here is the rule plainly. Next.js 16’s proxy.ts dispatches on either a default export or a named proxy function; both work. 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 you are wrapping several concerns into one function, and a default export of an anonymous wrapper reads worse.
Order is the part that actually matters. In the composed shape, i18n negotiation and the URL rewrite run first, before the auth gate. That ordering is not cosmetic. It means every layer downstream sees a request whose URL has already been resolved and prefixed: the auth check, the security headers, and the route itself. The auth gate from the auth-and-RBAC work and the nonce-based CSP from the security-baseline chapter slot in right after the handleI18n(request) call, operating on the response it hands back. This lesson owns only the i18n layer and the seam where the others attach; it does not write their code.
Both shapes carry the same config export. The matcher is the path pattern that decides which requests the proxy runs on. The negative-lookahead regex excludes API routes, framework internals, and any path with a file extension, so the proxy fires only on page routes.
This file’s existence now enforces the rule the last lesson stated: negotiation happens once, here. No component, no formatter, and no Server Action ever 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.
Per-request config: getRequestConfig
Section titled “Per-request config: getRequestConfig”i18n/request.ts is the conceptual heart of the wiring. It is the one seam where the resolved locale, the catalog, and the timezone all converge, and it runs once per request. This block does three distinct jobs, so step through it one highlight at a time.
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 rather than re-running the chain.
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. This is the same guard you wrote in the resolution chain, here as the last line of defense before a catalog gets loaded.
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, and the load-bearing performance move. Only the active locale’s catalog is bundled into this response, not all three. A static import of every catalog would ship the whole translation set to every request.
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 cross-chapter seam. The user’s timeZone, the profile column from the time-and-dates chapter, is attached here, so every format.dateTime downstream gets it without a manual argument. This is the only place the timezone enters the tree. Anonymous requests fall back to 'UTC'.
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 single per-request now anchor, and the one sanctioned Date seam in the tree, the same way the time-and-dates chapter let a Date live only at 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.
Hold onto the one idea this file exists to make true: locale, catalog, timezone, and now all originate here, and nothing downstream re-derives any of them. The rule from the Intl formatter lesson, that every date formatter requires a timeZone, is kept structurally, because the timezone is wired into the tree at this single seam rather than passed by hand at a hundred call sites. The resolution-chain lesson’s rule, that every render reads the resolved locale, holds for the same reason. This file is where both promises stop being discipline you have to remember and become the way the system is built.
Static rendering: setRequestLocale and generateStaticParams
Section titled “Static rendering: setRequestLocale and generateStaticParams”This is the new and genuinely error-prone rule of the lesson, and it is where production sites silently regress, so give it your attention.
Start with the failure, because the fix only makes sense once you feel the problem. Next.js 16 prefers static rendering , building a page’s HTML once at build time and serving it from a CDN. next-intl normally reads the locale dynamically, from the request headers, and reading headers forces the page to render per-request. For your authenticated app that is fine, because auth reads cookies and those pages are dynamic anyway. But for a public marketing page that should be static and CDN-cached, a dynamic header read is 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 the locale from the store instead of from headers, which lets the page go back to being static.
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.
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, so /xx-XX/dashboard 404s instead of rendering with a broken locale.
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, which re-enables static rendering. It must be called before any other next-intl call in the file.
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 load-bearing accessibility and SEO hook from the last lesson, driven by the resolved param, never by the cookie, which would mismatch the URL and break hydration.
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. It is shown bare here for brevity, and the next-but-one section is where it gets scoped properly. For now, just see that it wraps the tree.
Here is the rule, and it is 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. There is no error, no warning, and 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 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, so the static win does not apply there, but you still call setRequestLocale, both because it is the blanket rule and because it keeps locale resolution consistent. The guardrail is simple: never call headers() or cookies() in a public layout unless you mean to opt that page out of static rendering.
The following 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 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. Now you wire them, and the only thing to learn is when each one applies, because both speak the same dot-namespaced key and both run the same ICU engine underneath. The rule is one sentence: use* inside the render tree, get* outside it.
The following tabs show the same kind of call from three call sites.
import { useTranslations } from 'next-intl';
export function PastDueBadge() { const t = useTranslations('invoice.pastDue'); return <span>{t('title')}</span>;}The default for most rendering. useTranslations is synchronous and works in Server Components despite the use prefix, with no await and no async component needed.
'use client';import { useTranslations } from 'next-intl';
export function DismissButton() { const t = useTranslations('inbox.unread'); return <button>{t('dismiss')}</button>;}The same hook, the same call. Translations cross the Server/Client boundary transparently; the boundary you learned in the App Router unit is unchanged here. You do not switch APIs when you switch sides.
import { getTranslations } from 'next-intl/server';
export async function generateMetadata() { const t = await getTranslations('invoice.meta'); return { title: t('title') };}generateMetadata, Server Actions, and route handlers are async functions, not components, so a hook cannot apply. The async sibling getTranslations covers them. This is exactly where the next lesson’s localized page titles come from.
The boundary that decides which one you reach for is the render tree . If you are inside a component React renders, use useTranslations. If you are in generateMetadata, a Server Action, or a route handler, code that runs outside that tree, use getTranslations.
Embedded markup with t.rich
Section titled “Embedded markup with t.rich”The keys-and-catalogs lesson previewed t.rich and promised it here. This is the tool 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, and chunks is whatever text sits between the tags. The whole thing returns a ReactNode. This is the tool that 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 in the middle of the German sentence 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 to render translated content: t.rich gives you real components with none of the injection risk.
Formatting in the tree: useFormatter
Section titled “Formatting in the tree: useFormatter”In the Intl formatter lesson you built lib/format.ts for code outside React, such as utilities, tests, and scripts, and you threaded locale and timeZone through every call by hand because that code had no request to read them from. Inside the render tree you do not have to. useFormatter wires both automatically 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 engines from the Intl formatter lesson, now wired. format.number is Intl.NumberFormat, and notice the currency comes from invoice.currency, from the data, never hard-coded. format.dateTime is Intl.DateTimeFormat, and the timeZone that lib/format.ts required you to pass explicitly is now supplied by the request config: the mandatory-timezone promise from that lesson, kept by the wiring instead of by your discipline. format.relativeTime is Intl.RelativeTimeFormat, reading the per-request now anchor from getRequestConfig.
So there 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: it is the form for generateMetadata and Server Actions, where hooks do not apply. There is also an i18n/formats.ts file that holds shared formatter presets; it gets its real treatment in the project chapter, so for now just know the name exists.
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 a date.toLocaleString() sitting in a component is a finding: it has escaped the locale and timezone wiring and will format against the runtime’s defaults instead.
Sending translations to the client: NextIntlClientProvider scope
Section titled “Sending translations to the client: NextIntlClientProvider scope”This is a payload-size decision, and it is the one place next-intl v4’s defaults can quietly hurt you at scale.
Here is the setup. A client component that calls useTranslations needs its messages present in the client JavaScript bundle, because the server cannot reach into a client component and hand it strings at render time. NextIntlClientProvider is what puts those messages in the bundle. The trap is in the default. In v4, if you mount the provider with no messages prop, it automatically inherits every message and format from i18n/request.ts. A bare provider at the root therefore ships your entire catalog to every client: a 10,000-key translation set, now in the bundle of a page that uses four strings. In v3 you passed messages explicitly and this never happened. v4’s convenience default is “forward everything,” which is precisely the payload bug.
The following two versions show the difference between the bug and the discipline.
<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 the day your catalog grows to thousands of keys across features the client never touches.
import { getMessages } from 'next-intl/server';import { pick } from '@/lib/utils';
const messages = await getMessages();
<NextIntlClientProvider messages={pick(messages, ['inbox', 'invoice'])}> <ClientInbox /></NextIntlClientProvider>Wrap the smallest subtree, and pick only the namespaces that subtree’s client components consume. Passing an explicit messages prop overrides the auto-inherit. When a subtree needs no client messages at all, messages={null} is the full opt-out.
The rule is this. Server Components read translations directly and never need the provider, because they run on the server where the full catalog already lives. The provider exists for one job: ferrying the slice that client components consume. So you default to the smallest wrapping subtree and pick the namespaces it actually uses. This is the same class of decision as the Server/Client boundary from the App Router unit. Messages are just serializable props crossing into client code, and shipping the whole catalog is the same mistake as over-fetching a whole row when the component renders one field.
A word on the term that runs through all of this. The catalog is the per-locale JSON file, and scoping the provider is about how much of that file reaches the client.
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 noted that renaming a key is a coordinated change across the component and every catalog, and it leaned on an ESLint plugin to catch orphans. This file makes a typo like t('invoice.greting') a build error instead of a runtime missing-key, because tsc catches it before the code ever runs. next-intl derives the key types from your source catalog through TypeScript module augmentation .
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 give you two guarantees. Messages types every t(key) call against en-US.json, and because the source locale is your complete keyset (the completeness rule from the keys-and-catalogs lesson), a misspelled key or a missing placeholder argument is a compile error. Locale is the strict locale union for the whole app, derived from routing.locales. It is the same union as the Locale type in lib/i18n.ts, now also registered with next-intl so that useLocale() returns the narrow type instead of a bare string. A third member, Formats, joins this interface once i18n/formats.ts lands its shared format presets in the project chapter. AppConfig accepts all three keys, but only the ones you actually register.
With this file in place, the two things you have been tracking by hand are now compile-checked: the keyset from the keys-and-catalogs lesson and the locale union from the resolution-chain lesson. The “rename a key and update every catalog” coordination problem is now caught by the type checker, not just by a lint rule.
The wired rule, end to end
Section titled “The wired rule, end to end”Once the six files exist, the discipline collapses into a small, mechanical audit set. These are the checks, for you, for a reviewer, and for an agent working in the codebase:
- Every user-visible string goes through
t()ort.rich. A string literal in JSX is a finding. - Every number, date, and relative time in the tree goes through
useFormatterorgetFormatter. A bareIntl.X()or.toLocaleString()in a component is a finding. - Every navigation under
app/[locale]/imports from@/i18n/navigation. Anext/linkimport there is a finding. - Every
page.tsxandlayout.tsxunderapp/[locale]/starts withsetRequestLocale(locale). - The locale is negotiated once, in
proxy.ts, and never re-read downstream.
Each of those traces back to a file you now have. The point of the wiring was never the six files for their own sake; it was to make these rules true by construction, so that a single-locale launch already ships through the exact shape the second locale will need.
The following exercise checks the file-map model directly. Sort each i18n task into the file that owns it.
Drag each i18n responsibility onto the file that owns it. Drag each item into the bucket it belongs to, then press Check.
setRequestLocaleLinkExternal resources
Section titled “External resources”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.
The official wiring guide for the six files, including the routing, request, and navigation modules.
The reference for the request gate where the resolution chain runs and the matcher is configured.
When to reach for useTranslations vs getTranslations, and why messages stay on the server until the provider ferries a slice across.
The full AppConfig augmentation reference — typing Messages, Formats, and Locale so a misspelled key becomes a build error.