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.
| Strategy | URL | Strengths | Costs |
|---|---|---|---|
Sub-path |
|
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 |
Domain |
|
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 ( |
Cookie or header only |
|
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].
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.
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.
-
hasLocaleandnotFound(). Thelocaleparameter is typedstring, 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 bynext dev,next buildornext typegen, that typesparamsandchildrenfrom 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 whoselocaleparameter 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:
$ 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
Source: 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.
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).
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|.*\\..*).*)'],
}
Source: Internationalization: routing overview and proxy.js
The page was built and started with next start and every row below is the real response of that Proxy.
$ 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-lastcurlshows (?ref=ais kept). -
Static pages stay static. The
Set-Cookieheader 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 withSet-Cookie. -
A malformed or unmatched header must not throw.
matchraises aRangeError("Incorrect locale information provided") for headers such as*orxx_YY, so thetry/catchfalls 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/1is 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.
{
"title": "Bookshelf",
"search": "Search",
"reviews": "Reviews",
"by": "by {author}",
"language": "Language"
}
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]()
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>
)
}
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:
$ 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 withnpm install server-onlyif your project lacks it. -
Client Components receive strings, not dictionaries. A Client Component cannot call
getDictionary. Pass the few strings it needs as props (aslabelis 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 plainreplace. 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 asnext-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.
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)
}
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_cacheand 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.
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>
)
}
$ 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.
Source: generateMetadata: alternates
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:
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)
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)
Source: generateStaticParams
| Previous model | Cache Components | |
|---|---|---|
Listed locales |
|
|
Locale or id that was not listed |
|
|
|
The route is rendered dynamically. |
Build error: it must return at least one parameter. |
No |
|
Build error when a root parameter is read through |
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
generateStaticParamsand 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 callsnotFound()for the rest, and/en/fr/books/1is a 404 under both models, as thecurlcheck 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:
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:
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):
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,
}
})
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)
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:
{
"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:
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>
)
}
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>
)
}
'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:
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:
$ 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_LOCALEcookie, then theaccept-languageheader, thendefaultLocale. 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 supporteden-USandde-DEa request foren-GBmatchesen-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,/enredirected from/, as above); other settings such asas-neededallow an unprefixed default locale. Domain-based routing is available through thedomainssetting. -
Static rendering. With
next/root-params(Next.js 16.3 or later) the app is eligible for static rendering as soon as the layout exportsgenerateStaticParams; both caching models built, with the same route tables as the previous section. Before 16.3 the library neededsetRequestLocale(locale)in every layout and page, which its documentation now marks as legacy. -
Navigation. Use
Link,redirect,usePathnameanduseRouterfromi18n/navigation.tsinstead of the ones from Next.js, so thathref="/books/1"becomes/es/books/1. Alocaleprop onLink(or thelocaleoption ofrouter.replace) switches language. Localized pathnames (/de/ueber-uns) are available through thepathnamessetting. -
Cost.
next-intladds a dependency, a plugin innext.configand 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.
Source: next-intl: locale detection and next-i18next on GitHub
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.
/** @type {import('next').NextConfig} */
module.exports = {
i18n: {
locales: ['en', 'es', 'es-MX'],
defaultLocale: 'en',
},
}
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>
)
}
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>
}
Route (pages)
┌ ● /
├ ○ /404
└ /books/[id]
├ ● /en/books/1
├ ● /en/books/2
├ ● /en/books/3
└ ● [+6 more paths]
| Request | Result |
|---|---|
|
200, English. |
|
307 to |
|
307 to |
|
307 to |
|
200, stays on the default locale. The official page says an unlisted regional locale such as |
|
200 on |
|
200, English. Detection runs only at the root |
|
200, |
|
200, |
|
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 adomainslist (example.com/blog,example.fr/blog), where each domain has its own default locale and may list further locales that redirect to it. Thehttp: trueoption on a domain allows testing it locally. -
Detection. On
/, Next.js readsAccept-Languageand the current domain; setlocaleDetection: falseto turn the redirect off and use only the locale found in the domain or the path. TheNEXT_LOCALE=localecookie, set by your language switcher, has priority over the header. -
Reading the locale.
useRouter()exposeslocale,localesanddefaultLocale;getStaticPropsandgetServerSidePropsreceive them in their context;getStaticPathsreceiveslocalesanddefaultLocaleand can return alocaleper path (without it only the default locale is built). -
Switching.
next/linktakes alocaleprop;router.pushtakes\{ locale }in its options;locale={false}on aLinkwhosehrefalready contains the prefix opts out of automatic prefixing. -
Static generation. Each locale is a separate page. 50 locales times 10 non-dynamic
getStaticPropspages meansgetStaticPropsruns 500 times per build, so usefallbackmodes to build only the popular paths and render the rest on request. ReturningnotFound: truefromgetStaticPropsskips a locale for a page. -
SEO. Next.js adds
langto<html>, but variants of a page are unknown to it, sohreflangtags are your job (withnext/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 placeholderdefaultlocale pluslocaleDetection: falseand 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
i18nkey is unsupported in the App Router (the build warns, as shown earlier), andserverSideTranslationsis a data-fetching function that has no equivalent inasyncServer 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.
getStaticPropsandgetServerSidePropsare replaced by Server Components,generateStaticParamsand 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-i18next16.x documents App Router support and keeps the old API under thenext-i18next/pagespath, so a copy of the book’s imports fromnext-i18nextandnext-i18next/serverSideTranslationsneeds 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
| Mistake | What to do instead |
|---|---|
Reading |
Choose the locale in Proxy and put it in the URL; pages read only the |
Importing a dictionary module into a Client Component |
Mark the module |
Proxy matcher that includes |
Exclude them, as in the examples; otherwise images and Route Handlers get redirected to |
|
Wrap it in |
Forgetting |
Validate with |
No |
Return at least one value per root parameter, or the build fails. |
Relying on |
It is not available there; use |
Missing |
Add |
Hard-coded |
Prefix with the current locale, or use the locale-aware |
Adding the |
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 ( |
What Changed Recently
| Version | Change |
|---|---|
10.0 |
Built-in i18n routing for the Pages Router (the |
13.4 |
The App Router became stable; it has no |
15.0 |
|
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 ( |
16.3.0 |
|
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.
| Topic | In the books | Today (Next.js 16.3) |
|---|---|---|
Routing |
|
|
Locale detection |
Next.js reads |
A Proxy ( |
Translations |
|
Server-only dictionaries in async Server Components, |
Data functions |
|
|
Request APIs |
Synchronous |
|
Middleware |
Not part of the i18n recipe, or |
|
SEO |
|
The Metadata API: |