Internationalization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0, Node.js 20.9.0 or later, TypeScript 5.1 or later and next-intl 4.14.9 (published 2026-10-02). The main part covers the App Router, where internationalization is a convention you build from a dynamic segment, a Proxy and a dictionary loader; the last part covers the Pages Router, which still has a built-in i18n configuration key. Static generation is shown under both caching models, labelled previous model (the default) and Cache Components (cacheComponents: true), because the two behave differently for a [locale] segment. Every example was built and run with Next.js 16.3.8, Node.js 24.16 and npm 11.13 on macOS, in scratch copies of the Bookshelf (the catalogue of books that the rest of this section grows), and each is followed by the official page it derives from.

Internationalization (i18n) has two halves. Routing decides which locale a request is for, and is where Next.js helps. Localization decides what the user sees in that locale (translated text, plural rules, dates, numbers), and is ordinary application code that Next.js does not constrain. This page shows how the two meet in the App Router, how next-intl packages both halves, and how the Pages Router differs.

Locales and Routing Strategies

A locale is an identifier for a set of language and formatting preferences: en-US is English as spoken in the United States, nl-NL is Dutch as spoken in the Netherlands and nl is Dutch with no specific region. Both Next.js routers use the Unicode (UTS) locale identifier format, language-region-script, with region and script optional.

There are three ways to tell Next.js which locale a request is for, and they differ in what the URL looks like.

Table 1. Where the locale can live
Strategy URL Strengths Costs

Sub-path

/es/books/1

One domain, one certificate; every locale is a distinct, crawlable, cacheable URL; works with a static export of a single locale per build.

Every route is nested under a [locale] segment; links must carry the prefix.

Domain

example.es/books/1

Best for markets with their own brand and legal entity.

One domain per locale to manage; the App Router has no built-in support, so you route by host in Proxy, or use a library that does (next-intl supports it); the Pages Router has a domains option.

Cookie or header only

/books/1 for everyone

No URL changes.

Search engines see one URL with one language; shared links show whatever language the recipient prefers, and pages cannot be statically generated per locale, because the response depends on a request header or cookie.

The official guide recommends choosing the locale from the user’s language preferences in the browser: changing the preferred language changes the Accept-Language request header, and you read it to pick a locale. Sub-path routing is what the rest of this page builds, because it is the only strategy that is static-friendly, indexable and needs no extra infrastructure.

The app/[locale] Segment

In the App Router there is no i18n key to switch on. Internationalized routing is a dynamic segment at the top of the app folder: every special file (layouts, pages, not-found, loading, Route Handlers you want localized) lives under app/[locale], and Next.js forwards the locale parameter to every layout and page below it. The segment name is yours: the official guide calls it [lang], this page and next-intl call it [locale].

The folder layout used in this page
app/
  [locale]/
    layout.tsx             (1)
    page.tsx
    language-switcher.tsx
    dictionaries.ts        (2)
    dictionaries/
      en.json
      es.json
    books/
      [id]/
        page.tsx
  api/
    books/
      route.ts             (3)
proxy.ts                   (4)
1 The root layout lives inside [locale], so it renders <html lang>. There is no app/layout.tsx at all.
2 Server-only dictionary loader, shown below.
3 Route Handlers are not localized: they sit outside [locale] and the Proxy matcher skips them.
4 Redirects /books/1 to /en/books/1 or /es/books/1.

The root layout is where the document language is declared. params is a Promise (since Next.js 15, with synchronous access removed in 16), so the layout is async and awaits it.

app/[locale]/layout.tsx: a root layout that sets the document language
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { hasLocale, locales } from './dictionaries'

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }))
}

export const metadata: Metadata = { title: 'Bookshelf' }

export default async function RootLayout({
  children,
  params,
}: LayoutProps<'/[locale]'>) {
  const { locale } = await params
  if (!hasLocale(locale)) notFound()
  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  )
}

Three details matter here.

  • hasLocale and notFound(). The locale parameter is typed string, because the URL can contain anything. The guard narrows it to the supported locales and turns an unknown one into a 404 instead of a runtime error when the dictionary lookup fails.

  • LayoutProps<'/[locale]'> is a globally available type helper, generated by next dev, next build or next typegen, that types params and children from the route’s file path.

  • Moving the root layout under [locale] means navigating between two different root layouts triggers a full page load. Here there is one root layout whose locale parameter changes, so switching language is an ordinary navigation.

A folder app/[locale] matches any single first segment, so the Proxy below has a matcher that keeps files and internal paths away from the locale logic.

The i18n key of next.config is not used by the App Router. If you add it to a project that has only app/, the build prints a warning, and in the scratch app that also stripped the default locale prefix from the paths the build tried to prerender, so the build failed:

Adding the Pages Router i18n key to an App Router project
$ next build
⚠ i18n configuration in next.config.ts is unsupported in App Router.
...
Error: The provided export path '/books/1' doesn't match the '/[locale]/books/[id]' page.
Read more: https://nextjs.org/docs/messages/export-path-mismatch

Locale Detection in Proxy

A visitor who opens / or /books/1 has no locale in the URL. Something must pick one and redirect, and the official guide puts that in Proxy (proxy.ts, called middleware.ts before Next.js 16). Proxy runs before the request reaches a route, on the Node.js runtime by default since 16.0, so it can use ordinary npm packages. Its role in i18n is narrow: decide the locale and redirect. It should not translate anything, fetch dictionaries or touch a database.

The decision follows a fixed priority, shown below.

flowchart TD req(["Incoming request"]) --> prefixed{"Path already starts
with a supported locale?"} prefixed -- yes --> remember["Pass through and store
the choice in NEXT_LOCALE
if it changed"] prefixed -- no --> cookie{"Valid NEXT_LOCALE
cookie?"} cookie -- yes --> redirect["Redirect to /locale/path?query"] cookie -- no --> header{"Accept-Language
matches a locale?"} header -- yes --> redirect header -- no --> fallback["Use the default locale"] --> redirect

The locale of the header is chosen by negotiation. Accept-Language is a weighted list such as es-ES,es;q=0.9,en;q=0.5, and the user’s list rarely equals your list of supported locales. The official guide uses two small packages: negotiator parses the header into languages ordered by weight, and @formatjs/intl-localematcher picks the best supported locale for them. Install them with npm install negotiator @formatjs/intl-localematcher (and npm install --save-dev @types/negotiator for TypeScript).

proxy.ts: cookie first, then Accept-Language, then the default locale
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { NextResponse, type NextRequest } from 'next/server'

const locales = ['en', 'es']
const defaultLocale = 'en'

function getLocale(request: NextRequest): string {
  // 1. an explicit, previously stored choice wins
  const saved = request.cookies.get('NEXT_LOCALE')?.value
  if (saved && locales.includes(saved)) return saved

  // 2. otherwise negotiate with the Accept-Language header
  const headers = { 'accept-language': request.headers.get('accept-language') ?? '' }
  const languages = new Negotiator({ headers }).languages()
  try {
    return match(languages, locales, defaultLocale)
  } catch {
    return defaultLocale // malformed header such as "*" or garbage
  }
}

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl
  const current = locales.find(
    (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
  )

  if (current) {
    const response = NextResponse.next()
    // remember the choice, but only write the cookie when it changes
    if (request.cookies.get('NEXT_LOCALE')?.value !== current) {
      response.cookies.set('NEXT_LOCALE', current, {
        path: '/',
        maxAge: 60 * 60 * 24 * 365,
        sameSite: 'lax',
      })
    }
    return response
  }

  const url = request.nextUrl.clone()
  url.pathname = `/${getLocale(request)}${pathname}`
  return NextResponse.redirect(url) // keeps the query string
}

export const config = {
  // skip Route Handlers, internal files and anything with a dot (favicon.ico, images)
  matcher: ['/((?!api|_next|.*\\..*).*)'],
}

The page was built and started with next start and every row below is the real response of that Proxy.

Locale detection against the built app (only the interesting headers are kept)
$ curl -si localhost:3307/ | grep -iE '^(HTTP|location)'
HTTP/1.1 307 Temporary Redirect
location: /en
$ curl -si -H 'accept-language: es-ES,es;q=0.9,en;q=0.5' localhost:3307/ | grep -iE '^(HTTP|location)'
location: /es
$ curl -si -H 'accept-language: fr-FR,fr;q=0.9,en;q=0.5' 'localhost:3307/books/2?ref=a' | grep -i '^location'
location: /en/books/2?ref=a
$ curl -si -H 'accept-language: es' -H 'cookie: NEXT_LOCALE=en' localhost:3307/ | grep -i '^location'
location: /en
$ curl -si localhost:3307/es | grep -iE '^(HTTP|set-cookie|x-nextjs-cache)'
HTTP/1.1 200 OK
set-cookie: NEXT_LOCALE=es; Path=/; Expires=Sun, 03 Oct 2027 13:38:07 GMT; Max-Age=31536000; SameSite=lax
x-nextjs-cache: HIT

Five behaviours are worth knowing before you ship this.

  • The matcher is part of the design. A [locale] redirect must not fire for Route Handlers (/api/…​), the internal /_next/…​ files or static files with an extension. The negative lookahead above excludes exactly those; if you have legitimate paths with a dot (a username such as /users/jane.doe) add an explicit matcher entry for them.

  • The query string survives because the redirect is built from a clone of request.nextUrl, as the second-to-last curl shows (?ref=a is kept).

  • Static pages stay static. The Set-Cookie header is added by Proxy to the response of a prerendered page, and the page itself is still served from the cache (x-nextjs-cache: HIT). Writing the cookie only when the value changed keeps most responses cookie-free, which matters for CDNs that refuse to cache a response with Set-Cookie.

  • A malformed or unmatched header must not throw. match raises a RangeError ("Incorrect locale information provided") for headers such as * or xx_YY, so the try/catch falls back to the default locale. Without it, one bad request header becomes a 500 on every unprefixed URL.

  • An unsupported prefix is treated as a path. /fr/books/1 is not recognised as a locale, so the Proxy prefixes it and redirects to /en/fr/books/1, which then 404s. If you want a friendlier answer for locales you used to serve, list them in Proxy and redirect explicitly.

Language detection should be a first-visit convenience, not the only way to choose a language. Once the user picks a language in the interface (a link to /es), the stored cookie wins over the header, so the choice is remembered; that is the same role the NEXT_LOCALE cookie plays in the Pages Router. If you prefer URLs without a prefix for the default locale (/books/1 for English, /es/books/1 for Spanish), you do that with a Proxy rewrite rather than a redirect, and next-intl has a setting for it. Redirect and rewrite semantics, the Proxy lifecycle and the Edge middleware.ts deprecation are covered on the redirects, rewrites and Proxy page of this section.

Dictionaries in Server Components

A dictionary is an object that maps a key to a localized string. The official guide keeps one JSON file per locale and loads it with a small server-only helper.

app/[locale]/dictionaries/en.json (es.json has the same keys)
{
  "title": "Bookshelf",
  "search": "Search",
  "reviews": "Reviews",
  "by": "by {author}",
  "language": "Language"
}
app/[locale]/dictionaries.ts: lazy, server-only, typed
import 'server-only'

const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  es: () => import('./dictionaries/es.json').then((module) => module.default),
}

export type Locale = keyof typeof dictionaries

export const locales = Object.keys(dictionaries) as Locale[]
export const defaultLocale: Locale = 'en'

export const hasLocale = (locale: string): locale is Locale =>
  locale in dictionaries

export const getDictionary = async (locale: Locale) => dictionaries[locale]()
app/[locale]/page.tsx: reading the dictionary in an async Server Component
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { listBooks } from '@/lib/books'
import { getDictionary, hasLocale } from './dictionaries'
import { LanguageSwitcher } from './language-switcher'

export default async function Page({ params }: PageProps<'/[locale]'>) {
  const { locale } = await params
  if (!hasLocale(locale)) notFound()

  const dict = await getDictionary(locale)
  const books = await listBooks()
  return (
    <main>
      <h1>{dict.title}</h1>
      <LanguageSwitcher current={locale} label={dict.language} />
      <ul>
        {books.map((book) => (
          <li key={book.id}>
            <Link href={`/${locale}/books/${book.id}`}>{book.title}</Link>{' '}
            {dict.by.replace('{author}', book.author)}
          </li>
        ))}
      </ul>
    </main>
  )
}
app/[locale]/language-switcher.tsx: a language switcher that needs no JavaScript
import Link from 'next/link'
import { locales, type Locale } from './dictionaries'

export function LanguageSwitcher({ current, label }: { current: Locale; label: string }) {
  return (
    <nav aria-label={label}>
      {locales.map((locale) => (
        <Link
          key={locale}
          href={`/${locale}`}
          hrefLang={locale}
          lang={locale}
          aria-current={locale === current ? 'true' : undefined}
        >
          {locale.toUpperCase()}
        </Link>
      ))}
    </nav>
  )
}

The output served for /es and /en/books/…​ confirms the whole path works on the server, and that the page lands in the HTML with the right lang:

The Spanish and English pages
$ curl -s localhost:3307/es | grep -oE '<html[^>]*>|<h1>[^<]*</h1>|<a [^>]*hrefLang[^>]*>[^<]*'
<html lang="es">
<h1>Estantería</h1>
<a hrefLang="en" lang="en" href="/en">EN
<a hrefLang="es" lang="es" aria-current="true" href="/es">ES
$ curl -s localhost:3307/es/books/2 | grep -oE '<h1>[^<]*</h1>|<p>[^<]*</p>'
<h1>Neuromancer</h1>
<p>de William Gibson</p>

Why this shape works well:

  • The bundle does not grow with the number of languages. Layouts and pages are Server Components by default, so the dictionary code runs on the server and only the resulting HTML (and RSC payload) goes to the browser. Each locale file is loaded with a dynamic import(), so a request for Spanish never loads the English file.

  • import 'server-only' is a guard. It makes the build fail if a Client Component imports the module, which would otherwise ship every dictionary to the browser. The package is already installed in the scratch app; add it with npm install server-only if your project lacks it.

  • Client Components receive strings, not dictionaries. A Client Component cannot call getDictionary. Pass the few strings it needs as props (as label is passed above), or move the dictionary to a Context provider in a Client Component at the cost of serializing the whole object into the page. Passing a whole dictionary as a prop puts every key into the RSC payload, which is exactly the size problem the server-side design avoids.

  • The {author} placeholder here is a plain replace. That is fine for a demo with one variable and no plural forms; real messages need plural rules (1 book, 2 books; Spanish and Arabic differ), gender, number and date formatting. At that point a library such as next-intl (below) is cheaper than writing your own.

Sharing the locale with next/root-params

The helper above takes locale as an argument, so every caller must await params and pass it down. Next.js 16.3 added the next/root-params module (version history: v16.3.0, introduced), which exports one async getter per root parameter, the dynamic segments that sit above the root layout. With the root layout in app/[locale], locale is a root parameter, and any Server Component or server utility can read it without props.

app/[locale]/get-dictionary.ts: the locale comes from next/root-params
import { locale } from 'next/root-params'
import { notFound } from 'next/navigation'
import { getDictionary, hasLocale } from './dictionaries'

// No `server-only` needed: importing next/root-params in a Client Component already fails the build.
export async function getCurrentDictionary() {
  const current = await locale()
  if (!hasLocale(current)) notFound()
  return getDictionary(current)
}
app/[locale]/rp/page.tsx: no params, no props
import { getCurrentDictionary } from '../get-dictionary'

export default async function Page() {
  const dict = await getCurrentDictionary()
  return <h1>{dict.title}</h1>
}

It built without any flag on Next.js 16.3.8, was prerendered as /en/rp and /es/rp, and curl localhost:3307/es/rp returned <html lang="es"> and <h1>Estantería</h1>. Its restrictions are the interesting part:

  • Getters work in Server Components and server-side utilities only: not in Client Components (a build error), not in Server Actions and not, yet, in Route Handlers (support is planned).

  • The export name comes from the folder name, so it must be a valid JavaScript identifier; [post-slug] cannot be a root parameter.

  • Next.js tracks which getter a 'use cache' function called, so only that parameter becomes part of the cache key, and entries are not split by unrelated parameters.

  • The getter is not available inside the legacy unstable_cache and throws there; use 'use cache'.

  • With Cache Components every root parameter needs at least one value from generateStaticParams. Removing the function from the layout of the scratch app made the build fail:

    Error: A required root parameter (locale) was not provided in generateStaticParams for /[locale]/rp, please provide at least one value.

    Under the previous model the same pages simply became dynamic (ƒ), covered next.

Alternates and hreflang

<html lang> tells browsers and assistive technology the page language. Search engines additionally need to know that /en/books/2 and /es/books/2 are translations of each other, which is expressed with alternates in the Metadata API. Next.js does not do this for you in the App Router.

app/[locale]/books/[id]/page.tsx: canonical URL and language alternates
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getBook } from '@/lib/books'
import { getDictionary, hasLocale, locales } from '../../dictionaries'

export function generateStaticParams() {
  // the full cross product: every id in every locale
  return locales.flatMap((locale) => ['1', '2', '3'].map((id) => ({ locale, id })))
}

export async function generateMetadata({ params }: PageProps<'/[locale]/books/[id]'>): Promise<Metadata> {
  const { locale, id } = await params
  return {
    alternates: {
      canonical: `/${locale}/books/${id}`,
      languages: Object.fromEntries(locales.map((l) => [l, `/${l}/books/${id}`])),
    },
  }
}

export default async function Page({ params }: PageProps<'/[locale]/books/[id]'>) {
  const { locale, id } = await params
  if (!hasLocale(locale)) notFound()
  const book = await getBook(id)
  if (!book) notFound()
  const dict = await getDictionary(locale)
  return (
    <article>
      <h1>{book.title}</h1>
      <p>{dict.by.replace('{author}', book.author)}</p>
      <h2>{dict.reviews}</h2>
    </article>
  )
}
The generated link elements
$ curl -s localhost:3307/es/books/2 | grep -oE '<link rel="(canonical|alternate)"[^>]*>'
<link rel="canonical" href="/es/books/2"/>
<link rel="alternate" hrefLang="en" href="/en/books/2"/>
<link rel="alternate" hrefLang="es" href="/es/books/2"/>

The URLs here are relative because the scratch app sets no metadataBase; in production set metadataBase in the root layout so that they become absolute, which is what search engines expect. The metadata and SEO page of this section covers canonical URLs and alternates in depth.

Static Generation per Locale

A [locale] segment is a dynamic route, so by default each locale is rendered on demand. To prerender every locale at build time, export generateStaticParams from the layout (or from individual pages) and return one object per locale. Nested dynamic segments add their own parameters, so the Bookshelf’s book page returns the cross product of locales and ids, as the example above does. The official guide shows the layout-level form, which makes every page below it eligible for static generation.

The build output shows what happened. The same code, built twice:

Previous model (default): next build
Route (app)
┌ ○ /_not-found
├   /[locale]
│ ├ ● /en
│ └ ● /es
├   /[locale]/books/[id]
│ ├ ● /en/books/1
│ ├ ● /en/books/2
│ ├ ● /en/books/3
│ └ ● [+3 more paths]
└ ƒ /api/books

ƒ Proxy (Middleware)
Cache Components (CACHE_COMPONENTS=1, cacheComponents: true): next build
Route (app)
┌ ○ /_not-found
├   /[locale]
│ ├ ◐ /[locale]
│ ├ ○ /en
│ └ ○ /es
├   /[locale]/books/[id]
│ ├ ◐ /[locale]/books/[id]
│ ├ ◐ /en/books/[id]
│ ├ ○ /en/books/1
│ └ ◐ [+6 more paths]
└ ƒ /api/books

ƒ Proxy (Middleware)
Table 2. The same i18n code under the two caching models
Previous model Cache Components

Listed locales

● SSG: prerendered as static HTML, one file per locale and id.

○ static, plus ◐ partial-prerender entries that also cover values not listed (the [locale] and [id] shells).

Locale or id that was not listed

dynamicParams is true by default, so it is rendered on first request and then cached; export const dynamicParams = false makes it a 404 instead.

dynamicParams is not available when Cache Components is enabled; unknown values are served through the ◐ shells and rendered at request time, and a missing book is still a 404 (/es/books/9 returned 404 in the scratch app).

generateStaticParams returns an empty array

The route is rendered dynamically.

Build error: it must return at least one parameter.

No generateStaticParams at all

ƒ dynamic: /[locale], /[locale]/books/[id] render for every request.

Build error when a root parameter is read through next/root-params; otherwise the segment is a dynamic hole that must be read under <Suspense>.

In both models the locales and the product pages are static HTML. Serving /es returned x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000, and /es/books/9 (an id outside the list) returned a real 404. Two consequences follow for planning:

  • Build time grows with locales times pages. Ten locales and a thousand books is ten thousand HTML files. Return the popular combinations from generateStaticParams and let the rest render on first request, or, under Cache Components, serve the partial shell and fill the rest in.

  • Unsupported locales never reach the page. The Proxy redirects / to a supported locale, the layout calls notFound() for the rest, and /en/fr/books/1 is a 404 under both models, as the curl check showed.

Statically generating does not conflict with reading a cookie or Accept-Language: that work happens in Proxy, before the cache is consulted, and the pages themselves read only the locale parameter, which is known at build time. What does break static generation is reading cookies() or headers() inside the page to pick the locale, which is the trap of the cookie-only strategy in the first table.

next-intl

next-intl is the most used library for this problem in the App Router, and the one the Next.js guide links first. It adds what the hand-written version lacks: ICU message syntax (plurals, select, argument formatting), number, date and list formatting, typed messages, a Link and navigation hooks that know about the locale, and a Proxy factory that does detection, redirects, cookies and hreflang Link headers. Version 4.14.9 (published 2026-10-02) was used. Install with npm install next-intl.

The setup is five small files. First the routing definition, shared by Proxy, navigation and the layout:

i18n/routing.ts
import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['en', 'es'],
  defaultLocale: 'en',
})

The Proxy is one line. It replaces the 40 lines written by hand above, and its matcher is the same idea:

proxy.ts
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)

export const config = {
  // everything except /api, /trpc, /_next, /_vercel and files with a dot
  matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
}

The request configuration tells next-intl the locale and the messages for the current request. With Next.js 16.3 the locale can be read from next/root-params, so nothing has to be passed through the layout (before 16.3 this needed the experimental.rootParams option):

i18n/request.ts
import * as rootParams from 'next/root-params'
import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'

export default getRequestConfig(async ({ locale }) => {
  if (!locale) {
    const fromUrl = await rootParams.locale()
    if (hasLocale(routing.locales, fromUrl)) {
      locale = fromUrl
    } else {
      notFound()
    }
  }
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  }
})
next.config.ts: link request.ts to the library with the plugin
import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'

const nextConfig: NextConfig = {
  cacheComponents: process.env.CACHE_COMPONENTS === '1',
}

const withNextIntl = createNextIntlPlugin()
export default withNextIntl(nextConfig)
i18n/navigation.ts: locale-aware Link, redirect and hooks
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'

// locale-aware wrappers around next/link and next/navigation
export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing)

Messages are JSON files per locale in ICU syntax. Here a plural and a currency argument:

messages/en.json (es.json has the same keys)
{
  "Home": {
    "title": "Bookshelf",
    "bookCount": "{count, plural, =0 {No books} one {# book} other {# books}}",
    "by": "by {author}",
    "price": "Price: {price, number, ::currency/EUR}"
  },
  "LocaleSwitcher": {
    "label": "Language",
    "locale": "{locale, select, en {English} es {Spanish} other {Unknown}}"
  }
}

The layout wraps the tree in NextIntlClientProvider, which hands the locale and messages to Client Components, and validates the segment. Server Components can use the synchronous useTranslations hook (it is not a React state hook; it reads request data and works in components that are not async), while async components use the awaitable getTranslations:

app/[locale]/layout.tsx
import type { Metadata } from 'next'
import { hasLocale, NextIntlClientProvider } from 'next-intl'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { routing } from '@/i18n/routing'

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }))
}

export async function generateMetadata({ params }: LayoutProps<'/[locale]'>): Promise<Metadata> {
  const { locale } = await params
  const t = await getTranslations({ locale, namespace: 'Home' })
  return { title: t('title') }
}

export default async function RootLayout({ children, params }: LayoutProps<'/[locale]'>) {
  const { locale } = await params
  if (!hasLocale(routing.locales, locale)) notFound()
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider>{children}</NextIntlClientProvider>
      </body>
    </html>
  )
}
app/[locale]/page.tsx: a synchronous Server Component using useTranslations
import { useTranslations } from 'next-intl'
import { Link } from '@/i18n/navigation'
import { LocaleSwitcher } from '@/components/locale-switcher'

const books = [
  { id: '1', title: 'Dune', author: 'Frank Herbert' },
  { id: '2', title: 'Neuromancer', author: 'William Gibson' },
  { id: '3', title: 'Solaris', author: 'Stanislaw Lem' },
]

export default function Page() {
  const t = useTranslations('Home')
  return (
    <main>
      <h1>{t('title')}</h1>
      <LocaleSwitcher />
      <p>{t('bookCount', { count: books.length })}</p>
      <ul>
        {books.map((book) => (
          <li key={book.id}>
            <Link href={`/books/${book.id}`}>{book.title}</Link> {t('by', { author: book.author })}
          </li>
        ))}
      </ul>
    </main>
  )
}
components/locale-switcher.tsx: a Client Component that changes only the locale
'use client'

import { useLocale, useTranslations } from 'next-intl'
import { useParams } from 'next/navigation'
import { usePathname, useRouter } from '@/i18n/navigation'
import { routing } from '@/i18n/routing'

export function LocaleSwitcher() {
  const t = useTranslations('LocaleSwitcher')
  const locale = useLocale()
  const router = useRouter()
  const pathname = usePathname()
  const params = useParams()

  return (
    <select
      aria-label={t('label')}
      value={locale}
      onChange={(event) => {
        // keep the same page, change only the locale (also updates the cookie)
        router.replace(
          // @ts-expect-error -- pathname and params always match for the current route
          { pathname, params },
          { locale: event.target.value }
        )
      }}
    >
      {routing.locales.map((code) => (
        <option key={code} value={code}>
          {t('locale', { locale: code })}
        </option>
      ))}
    </select>
  )
}

A page that formats a price shows what the library adds over the hand-written version. getFormatter follows the request locale, and the ICU argument in the message does the same job inside a string:

app/[locale]/books/[id]/page.tsx (next-intl version)
import { getFormatter, getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { getBook } from '@/lib/books'
import { routing } from '@/i18n/routing'

export function generateStaticParams() {
  return routing.locales.flatMap((locale) => ['1', '2', '3'].map((id) => ({ locale, id })))
}

export default async function Page({ params }: PageProps<'/[locale]/books/[id]'>) {
  const { id } = await params
  const book = await getBook(id)
  if (!book) notFound()
  const t = await getTranslations('Home')
  const format = await getFormatter()
  return (
    <article>
      <h1>{book.title}</h1>
      <p>{t('by', { author: book.author })}</p>
      <p>{t('price', { price: book.priceCents / 100 })}</p>
      <p>{format.number(book.priceCents / 100, { style: 'currency', currency: 'EUR' })}</p>
    </article>
  )
}

Built and run with next start, it behaved as follows:

next-intl: detection, cookies, alternates and formatting
$ curl -si localhost:3307/ | grep -iE '^(HTTP|location|set-cookie)'
HTTP/1.1 307 Temporary Redirect
location: /en
set-cookie: NEXT_LOCALE=en; Path=/; SameSite=lax
$ curl -si -H 'accept-language: es-ES,es;q=0.9' localhost:3307/ | grep -i '^location'
location: /es
$ curl -si -H 'accept-language: es' -H 'cookie: NEXT_LOCALE=en' localhost:3307/ | grep -i '^location'
location: /en
$ curl -si localhost:3307/es | grep -i '^link'
link: <http://localhost:3307/en>; rel="alternate"; hreflang="en", <http://localhost:3307/es>; rel="alternate"; hreflang="es", <http://localhost:3307/>; rel="alternate"; hreflang="x-default"
$ curl -s localhost:3307/es | grep -oE '<html[^>]*>|<title>[^<]*</title>|<p>[^<]*</p>'
<html lang="es">
<title>Estantería</title>
<p>3 libros</p>
$ curl -s localhost:3307/en/books/2 | grep -oE '<p>[^<]*</p>'
<p>by William Gibson</p>
<p>Price: €8.99</p>
<p>€8.99</p>
$ curl -s localhost:3307/es/books/2 | grep -oE '<p>[^<]*</p>'
<p>de William Gibson</p>
<p>Precio: 8,99 €</p>
<p>8,99 €</p>

Things next-intl decides that are worth knowing:

  • Detection priority for prefix-based routing is: the locale prefix in the path, then the NEXT_LOCALE cookie, then the accept-language header, then defaultLocale. This is the same order as the hand-written Proxy above.

  • Matching algorithm. The header is matched with the "best fit" algorithm of @formatjs/intl-localematcher, which compares a distance between the requested and supported locales. With supported en-US and de-DE a request for en-GB matches en-US, where the stricter RFC 4647 "lookup" algorithm would fall back to the default locale.

  • Prefix. The default is localePrefix: 'always' (every locale in the path, /en redirected from /, as above); other settings such as as-needed allow an unprefixed default locale. Domain-based routing is available through the domains setting.

  • Static rendering. With next/root-params (Next.js 16.3 or later) the app is eligible for static rendering as soon as the layout exports generateStaticParams; both caching models built, with the same route tables as the previous section. Before 16.3 the library needed setRequestLocale(locale) in every layout and page, which its documentation now marks as legacy.

  • Navigation. Use Link, redirect, usePathname and useRouter from i18n/navigation.ts instead of the ones from Next.js, so that href="/books/1" becomes /es/books/1. A locale prop on Link (or the locale option of router.replace) switches language. Localized pathnames (/de/ueber-uns) are available through the pathnames setting.

  • Cost. next-intl adds a dependency, a plugin in next.config and a request-configuration file. For two languages and thirty strings the hand-written dictionary is smaller; once you need plurals, formatting, typed keys or translator-friendly messages, the library pays for itself.

Other libraries exist and the Next.js guide lists some of them (next-international, next-i18n-router); i18next with react-i18next is the large non-Next-specific ecosystem, and next-i18next (version 16.3.1 on npm, published 2026-09-13) is the Next.js adapter for it. Its current README documents an App Router mode (getT() for Server Components, useT() for Client Components, createProxy() for detection) next to the old Pages Router API, which now lives under the next-i18next/pages import path. That App Router mode was read from its documentation and not built here.

The Pages Router i18n Config

The Pages Router is the one router with built-in i18n. Since Next.js 10 you describe the locales in next.config.js and Next.js does the routing, the detection and the locale plumbing; there is no [locale] folder and no Proxy needed for the basic case.

next.config.js (Pages Router)
/** @type {import('next').NextConfig} */
module.exports = {
  i18n: {
    locales: ['en', 'es', 'es-MX'],
    defaultLocale: 'en',
  },
}
pages/index.tsx: the locale in getStaticProps and in the router
import type { GetStaticProps } from 'next'
import Link from 'next/link'
import { useRouter } from 'next/router'

type Props = { title: string }

const titles: Record<string, string> = { en: 'Bookshelf', es: 'Estantería' }

export const getStaticProps: GetStaticProps<Props> = async ({ locale }) => {
  const language = (locale ?? 'en').split('-')[0]
  return { props: { title: titles[language] ?? titles.en } }
}

export default function Home({ title }: Props) {
  const { locale, locales, defaultLocale, asPath } = useRouter()
  return (
    <main>
      <h1>{title}</h1>
      <p id="info">locale={locale} default={defaultLocale} all={locales?.join(',')}</p>
      <nav>
        {locales?.map((code) => (
          <Link key={code} href={asPath} locale={code}>
            {code}
          </Link>
        ))}
      </nav>
      <Link href="/books/1">Dune</Link>
    </main>
  )
}
pages/books/[id].tsx: one static page per book and locale
import type { GetStaticPaths, GetStaticProps } from 'next'

export const getStaticPaths: GetStaticPaths = async ({ locales = [] }) => ({
  // one entry per book and locale; omit `locale` and only the default locale is built
  paths: locales.flatMap((locale) =>
    ['1', '2', '3'].map((id) => ({ params: { id }, locale }))
  ),
  fallback: 'blocking',
})

export const getStaticProps: GetStaticProps<{ id: string; locale: string }> = async ({ params, locale }) => {
  const id = String(params?.id)
  if (!['1', '2', '3'].includes(id)) return { notFound: true }
  return { props: { id, locale: locale ?? 'en' } }
}

export default function Book({ id, locale }: { id: string; locale: string }) {
  return <h1>Book {id} in {locale}</h1>
}
next build: nine book pages for three locales
Route (pages)
┌ ● /
├ ○ /404
└   /books/[id]
  ├ ● /en/books/1
  ├ ● /en/books/2
  ├ ● /en/books/3
  └ ● [+6 more paths]
Table 3. Observed behaviour of that configuration
Request Result

GET / with no Accept-Language

200, English.

GET / with accept-language: es

307 to /es.

GET / with accept-language: es-MX

307 to /es-MX.

GET / with accept-language: es-AR,es;q=0.9

307 to /es.

GET / with accept-language: es-AR alone

200, stays on the default locale. The official page says an unlisted regional locale such as nl-BE goes to nl if available; in this test it did not, so list the regional variants you serve or rely on a language-only entry plus a full header.

GET / with accept-language: es and cookie: NEXT_LOCALE=en

200 on /: the cookie beats the header.

GET /books/1 with accept-language: es

200, English. Detection runs only at the root /.

GET /es-MX

200, <html lang="es-MX">, locale=es-MX: Next.js adds the lang attribute itself.

GET /es/books/2, GET /books/3

200, Book 2 in es and Book 3 in en.

GET /es/books/9, GET /fr

404.

What the official page documents about this model, beyond the example:

  • Strategies. Sub-path routing (the default locale has no prefix: /blog, /fr/blog, /nl-nl/blog) or domain routing with a domains list (example.com/blog, example.fr/blog), where each domain has its own default locale and may list further locales that redirect to it. The http: true option on a domain allows testing it locally.

  • Detection. On /, Next.js reads Accept-Language and the current domain; set localeDetection: false to turn the redirect off and use only the locale found in the domain or the path. The NEXT_LOCALE=locale cookie, set by your language switcher, has priority over the header.

  • Reading the locale. useRouter() exposes locale, locales and defaultLocale; getStaticProps and getServerSideProps receive them in their context; getStaticPaths receives locales and defaultLocale and can return a locale per path (without it only the default locale is built).

  • Switching. next/link takes a locale prop; router.push takes \{ locale } in its options; locale={false} on a Link whose href already contains the prefix opts out of automatic prefixing.

  • Static generation. Each locale is a separate page. 50 locales times 10 non-dynamic getStaticProps pages means getStaticProps runs 500 times per build, so use fallback modes to build only the popular paths and render the rest on request. Returning notFound: true from getStaticProps skips a locale for a page.

  • SEO. Next.js adds lang to <html>, but variants of a page are unknown to it, so hreflang tags are your job (with next/head).

  • Limits. 100 locales and 100 domains. Internationalized routing does not work with output: 'export'. The official page also describes a Proxy workaround for prefixing the default locale (a placeholder default locale plus localeDetection: false and a redirect).

Why the books' next-i18next approach is legacy

The two consulted books follow the Pages Router recipe that was standard in the Next.js 12 and 13 era: set the i18n key, wrap _app with appWithTranslation, and call serverSideTranslations(locale, ['common']) from getStaticProps or getServerSideProps to load JSON namespaces per page. It still works in the Pages Router, and the configuration shown in this section is still supported, but it is legacy for three reasons.

  • It is tied to the Pages Router. The i18n key is unsupported in the App Router (the build warns, as shown earlier), and serverSideTranslations is a data-fetching function that has no equivalent in async Server Components. For new work the App Router uses a [locale] segment, a Proxy and a dictionary loader, or a library built for it.

  • The data functions it hooks into are the legacy ones. getStaticProps and getServerSideProps are replaced by Server Components, generateStaticParams and caching; translations loaded through page props are also serialized into the page data of every page that asks for them.

  • The package itself moved. next-i18next 16.x documents App Router support and keeps the old API under the next-i18next/pages path, so a copy of the book’s imports from next-i18next and next-i18next/serverSideTranslations needs updating, and following the 2022 chapters verbatim on a current project is likely to fail.

If you are on the Pages Router today, keep the built-in i18n config and either next-i18next or next-intl (its documentation has a Pages Router guide); the Next.js page lists many compatible libraries. If you are on the App Router, use the structure of this page.

Pitfalls

Table 4. Common internationalization mistakes
Mistake What to do instead

Reading cookies() or headers() in a page to choose the language

Choose the locale in Proxy and put it in the URL; pages read only the locale parameter and stay static.

Importing a dictionary module into a Client Component

Mark the module server-only; pass strings as props, or use a library with a client provider that sends only the namespaces needed.

Proxy matcher that includes /api, /_next or files with extensions

Exclude them, as in the examples; otherwise images and Route Handlers get redirected to /en/…​.

match from the locale matcher throws on a malformed header

Wrap it in try/catch and fall back to the default locale.

Forgetting notFound() for an unknown locale

Validate with hasLocale in the layout and pages.

No generateStaticParams under Cache Components

Return at least one value per root parameter, or the build fails.

Relying on dynamicParams under Cache Components

It is not available there; use generateStaticParams and notFound().

Missing hreflang alternates

Add alternates.languages in generateMetadata (or `next-intl’s Link header) so each translation points to the others.

Hard-coded /books/1 links

Prefix with the current locale, or use the locale-aware Link of next-intl.

Adding the i18n key to an App Router project

Remove it; the App Router warns and the build can fail.

Setting the preferred locale in a cookie with user input, unvalidated

Always check it against the list of supported locales before using it in a redirect or file path; the examples above do (locales.includes(saved)).

What Changed Recently

Table 5. Internationalization changes, by Next.js version
Version Change

10.0

Built-in i18n routing for the Pages Router (the i18n config key and automatic locale detection).

13.4

The App Router became stable; it has no i18n key and relies on a [locale] segment plus Middleware.

15.0

params and searchParams became promises, so layouts and pages that read locale are async and await params.

15.5

Middleware could use the Node.js runtime (stable), so locale libraries that need Node.js APIs became usable there.

16.0

Middleware was renamed Proxy (proxy.ts) and defaults to the Node.js runtime; synchronous access to params was removed.

16.3.0

next/root-params was introduced, and the official internationalization guide and the next-intl setup guide both read the locale from it.

Other facts from the sources used on this page, with the date they were verified (2026-10-03): the next-intl 4.14.9 setup reads the locale from next/root-params and its documentation marks setRequestLocale as legacy; next-i18next 16.3.1 documents both routers.

Where the Books Are Outdated

Real-World Next.js (Packt, 2022) and Practical Next.js for E-Commerce (Apress, 2023) describe internationalization for the Pages Router. They are not wrong for that router, but the approach has moved.

Table 6. Internationalization topics where the books are behind
Topic In the books Today (Next.js 16.3)

Routing

i18n key in next.config.js, with locales and a default locale, and sub-path routing from the Pages Router.

app/[locale] segments for the App Router, with generateStaticParams per locale. The i18n key still works in the Pages Router only.

Locale detection

Next.js reads Accept-Language on / and redirects.

A Proxy (proxy.ts, Node.js runtime) negotiates the header with negotiator and @formatjs/intl-localematcher, or next-intl’s Proxy does it, and honours a `NEXT_LOCALE cookie.

Translations

next-i18next with appWithTranslation and serverSideTranslations in getStaticProps.

Server-only dictionaries in async Server Components, next/root-params for the locale, or next-intl; next-i18next 16 targets both routers and moved its Pages API to next-i18next/pages.

Data functions

getStaticProps, getStaticPaths with a locale per path, getServerSideProps.

generateStaticParams, Server Components, caching under the previous model or Cache Components.

Request APIs

Synchronous context.locale, router.locale.

params is a Promise; await params, or read next/root-params.

Middleware

Not part of the i18n recipe, or middleware.ts.

proxy.ts (renamed in 16.0).

SEO

next/head with hreflang tags.

The Metadata API: alternates.languages in generateMetadata.