Internationalization
Serve a Next.js app in several languages: a [lang] segment, choosing the language in proxy.ts, dictionaries on the server, prerendering and hreflang.
- 01Give every page a language in its addressPut the app under a [lang] folder, set <html lang>, and answer 404 for a language the shop doesn't have.
- 02Choose and remember a visitor's languageRead Accept-Language in proxy.ts, send the visitor to their language, and save their own choice in a cookie.
- 03Translate without slowing anyone downLoad dictionaries on the server only, prerender every language with Cache Components, and add hreflang links.
One shop, two languages
The tea shop now sells in France too. Every page needs to exist in English and in French, at an address of its own, so a French visitor can share a link to the French page and a search engine can list both. Serving an app in several languages is called internationalization, often written i18n (an i, 18 letters, an n). Each language, with the way it writes dates and numbers, is a locale: en for English, fr for French, or fr-CA for French as written in Canada.
Next.js's docs describe two ways to put the language in the address: as the first part of the path, /fr/teas, or as a domain of its own, tea-shop.fr/teas. This shop uses the path. That's a dynamic segment: a folder named [lang] at the top of app, around every page. Here are the shop's files:
app/
[lang]/
dictionaries/
en.json
fr.json
dictionaries.ts
layout.tsx
page.tsx
i18n.tsen.json and fr.json are the shop's dictionaries: the same keys, with each language's words. The home page reads the one for its address. Here is the shop, built with next build and run with next start from Next.js 16.4.3:
app/[lang]/page.tsximport Link from "next/link";import { notFound } from "next/navigation";import { isLocale } from "../../i18n";import { getDictionary } from "./dictionaries"; export default async function Home({ params }: PageProps<"/[lang]">) { const { lang } = await params; if (!isLocale(lang)) notFound(); const words = await getDictionary(lang); return ( <main> <h1>{words.title}</h1> <p>{words.intro}</p> <Link href={`/${lang}/teas`}>{words.teas}</Link> </main> );}Du thé frais, envoyé chaque semaine.
Nos thésThe page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Line 7 of app/[lang]/page.tsx waits for params, a promise of the address's values, and takes lang from it: "fr" for /fr. Line 8 stops if it isn't a language the shop has. Line 9 loads that language's words with getDictionary, and lines 12 and 13 show two of them. The Browser tab opens on /fr: Boutique de thé and Du thé frais, envoyé chaque semaine. Pick /en above the page for Tea shop. Then open the Terminal tab: under /[lang], the build lists ○ /en and ○ /fr. Both pages were made during the build, with their words already in the HTML.
app/[lang], serves both addresses. The part of the address in the [lang] place reaches the page as params.lang, and the page reads that language's dictionary.A restaurant prints its menu twice from one layout, once with each language's words. Guests never see the word lists, only the printed menus. At the door, the host hears which language a new guest speaks and hands them that menu. A regular who once asked for the French one gets it again without asking.
- In real life: One menu layout, printed twice
- In Next.js: One
app/[lang]folder, prerendered forenandfr - In real life: A word list for each language, kept in the office
- In Next.js: A dictionary for each language, read on the server
- In real life: The host hears which language a guest speaks
- In Next.js: proxy.ts reads the
Accept-Languageheader - In real life: A regular asks for the French menu again
- In Next.js: A
langcookie remembers the visitor's choice
Put the language in the address with an app/[lang] folder, and let proxy.ts send each visitor to theirs. Every page reads lang from its params and loads that language's dictionary on the server. With generateStaticParams, the build makes every page in every language ahead of time.
The [lang] folder and the lang attribute
In this shop there's no app/layout.tsx. The root layout, the one that renders <html>, is app/[lang]/layout.tsx, inside the dynamic segment, so it gets lang too:
import { notFound } from "next/navigation";import { isLocale, locales } from "../../i18n"; export function generateStaticParams() { return locales.map((lang) => ({ lang }));} export default async function RootLayout({ children, params }: LayoutProps<"/[lang]">) { const { lang } = await params; if (!isLocale(lang)) notFound(); return ( <html lang={lang}> <body>{children}</body> </html> );} Line 9 takes lang from params. Line 12 writes it into <html lang>. That attribute tells the browser and assistive technology which language the page is in: screen readers use it to pick a voice, and browsers use it when they offer to translate a page. The recordings show it: /fr/teas starts with <html lang="fr">, and /en with <html lang="en">.
Line 10 checks the value with isLocale, from the shop's i18n.ts, which lists its languages. Any text can sit in the [lang] place, so /de reaches this layout with lang set to "de". The shop has no German, so line 10 calls notFound(). The recording of /de answered 404. Its HTML has no text: the root layout itself called notFound(), and there's no layout above it to show a not-found page in. Next.js sent a page whose <html> has the id __next_error__, and in Chrome its own JavaScript drew 404: This page could not be found. The first example's Steps tab shows it. For a 404 page of your own in a shop like this, Next.js's docs point to the experimental global-not-found.tsx (Not found and access errors covers 404 pages).
Links inside the shop keep the language: line 14 of the home page links to `/${lang}/teas`, so a French visitor stays in French.
Choosing the language in proxy.ts
A visitor who types the shop's address, /, gives no language. Next.js's docs suggest using the language the browser asks for. Every browser sends it, with each request, in the Accept-Language header: the languages the visitor set in their browser or computer, best first. A Canadian visitor's browser might send fr-CA,fr;q=0.9,en;q=0.8. Each language after the first has a weight, q, from 0 to 1: how much the visitor wants it. No q means 1. (Request-time APIs read this header in a page.)
Picking the language has to happen before any page renders, and on every address without a language, so it belongs in proxy.ts. The shop keeps the choice itself in i18n.ts, at the project's root:
export const locales = ["en", "fr"] as const;export type Locale = (typeof locales)[number];export const defaultLocale: Locale = "en";export const languageNames: Record<Locale, string> = { en: "English", fr: "Français" }; export function isLocale(value: string | undefined): value is Locale { return locales.includes(value as Locale);} // The languages an Accept-Language header asks for, best first:// "fr-CA,fr;q=0.9,en;q=0.8" gives ["fr-ca", "fr", "en"].export function preferredLanguages(header: string) { return header .split(",") .map((part) => { const [tag, ...params] = part.split(";").map((piece) => piece.trim()); const weight = params.find((param) => /^q=/i.test(param)); return { tag: tag.toLowerCase(), q: weight === undefined ? 1 : Number(weight.slice(2)) }; }) // A weight goes from 0 to 1. 0 means "not this one", and anything else isn't a weight. .filter((item) => item.tag && item.q > 0 && item.q <= 1) .sort((a, b) => b.q - a.q) .map((item) => item.tag);} // The saved choice first, then the browser's languages in order, then English.export function pickLocale(acceptLanguage: string, saved?: string): Locale { if (isLocale(saved)) return saved; for (const tag of preferredLanguages(acceptLanguage)) { const language = tag.split("-")[0]; if (isLocale(language)) return language; } return defaultLocale;} Line 1 lists the shop's languages, line 4 gives each one's name in that language, and isLocale on lines 6 to 8 checks a value against them. preferredLanguages, on lines 12 to 24, reads the header. It splits it at each comma, and line 16 splits each part at its semicolons and trims the spaces around them, so fr;q=0.9 and fr; q=0.9 read the same. Line 17 finds the weight, written q= or Q=, and line 18 reads its number, or 1 when there's none. Line 21 keeps only weights above 0 and up to 1: 0 means "not this language", and a weight like 2 or abc isn't a weight at all, so that part is dropped. Line 22 sorts the rest, highest weight first. pickLocale, on lines 27 to 34, decides: line 28 returns a saved choice, the lang cookie, if there is one. Otherwise lines 29 to 32 go through the header's languages and keep the first whose language part is one the shop has: line 30 turns fr-ca into fr. Line 33 falls back to English.
And proxy.ts sends the visitor there:
proxy.tsimport { NextResponse, type NextRequest } from "next/server";import { locales, pickLocale } from "./i18n"; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const hasLocale = locales.some((lang) => pathname === `/${lang}` || pathname.startsWith(`/${lang}/`)); if (hasLocale) return NextResponse.next(); const lang = pickLocale(request.headers.get("accept-language") ?? "", request.cookies.get("lang")?.value); request.nextUrl.pathname = `/${lang}${pathname}`; return NextResponse.redirect(request.nextUrl);} export const config = { matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"],};GET /307
- Sent with the request
accept-languagefr-CA,fr;q=0.9,en;q=0.8
- Response headers
location/fr
No HTML arrived: the body is only the new address, as plain text
/frLine 6 checks whether the path already starts with a language, like /fr or /fr/teas; then line 7 lets the request through. Otherwise line 9 asks pickLocale, with the header and the lang cookie, line 10 puts the language in front of the path, and line 11 redirects. Each row is one recorded request, and where it went:
| The request | What pickLocale finds | The answer |
|---|---|---|
/, with fr-CA,fr;q=0.9,en;q=0.8 | fr-ca first; its language, fr, is one the shop has. | 307 to /fr. |
/, with de-DE,de;q=0.9 | Only German, which the shop doesn't have. | 307 to /en, the default. |
/, with de-DE,de;q=0.9,fr;q=0.5 | German first, then fr, the first one the shop has. | 307 to /fr. |
/, with the French header and the cookie lang=en | The saved choice, en, before the header. | 307 to /en. |
/teas, with the French header | fr, as in the first row. | 307 to /fr/teas, the same page in French. |
/fr/teas | Nothing to choose: the address has a language. | 200, the French menu. |
pickLocale tries a saved choice first, then each language the browser asks for, best first, then English. Here German comes first, but the shop doesn't have it, so fr wins and proxy.ts answers 307 to /fr.Try other headers. The box runs a copy of i18n.ts, and the result says what proxy.ts would answer for /:
export const locales = ["en", "fr"] as const;export type Locale = (typeof locales)[number];export const defaultLocale: Locale = "en";export const languageNames: Record<Locale, string> = { en: "English", fr: "Français" }; export function isLocale(value: string | undefined): value is Locale { return locales.includes(value as Locale);} // The languages an Accept-Language header asks for, best first:// "fr-CA,fr;q=0.9,en;q=0.8" gives ["fr-ca", "fr", "en"].export function preferredLanguages(header: string) { return header .split(",") .map((part) => { const [tag, ...params] = part.split(";").map((piece) => piece.trim()); const weight = params.find((param) => /^q=/i.test(param)); return { tag: tag.toLowerCase(), q: weight === undefined ? 1 : Number(weight.slice(2)) }; }) // A weight goes from 0 to 1. 0 means "not this one", and anything else isn't a weight. .filter((item) => item.tag && item.q > 0 && item.q <= 1) .sort((a, b) => b.q - a.q) .map((item) => item.tag);} // The saved choice first, then the browser's languages in order, then English.export function pickLocale(acceptLanguage: string, saved?: string): Locale { if (isLocale(saved)) return saved; for (const tag of preferredLanguages(acceptLanguage)) { const language = tag.split("-")[0]; if (isLocale(language)) return language; } return defaultLocale;} What pickLocale sees
- Saved choice:
none - The header's languages, best first:
de-dedefr - First one the shop has:
fr, languagefr - Chosen:
fr
No saved choice, so lines 29 to 32 go through the header's languages, best first, and keep the first whose language the shop has.
Line 15 of proxy.ts is its matcher. It skips api, Next.js's own files, and any path with a dot in it, like /favicon.ico or /leaf.svg. The part .*\\..* is a regular expression inside a string: the doubled backslash makes \., a real dot, so it means anything, a dot, then anything. That matters here more than in most apps. A file in public has no language, so a proxy.ts that runs for it would send /leaf.svg to /en/leaf.svg, which doesn't exist. With this matcher, the recorded /leaf.svg answered 200 with the picture. The rule skips real pages too: an address like /blog/v1.2 would never reach proxy.ts. Matchers in the array add up, so to send such pages through it again, add a second entry that names them, like "/blog/:path*".
pickLocale handles weights, spaces and regions, which is what most visitors send. Next.js's docs use two npm packages instead: negotiator reads the header, and @formatjs/intl-localematcher matches its languages against yours with the locale-matching algorithms from the specification behind JavaScript's Intl APIs. Use them when your languages include scripts or regions of their own, like zh-Hant or pt-BR next to pt-PT.
Remembering the visitor's choice
The header is a guess. Someone in Paris may prefer the English shop. So the layout gets a button for each language in locales, labeled from languageNames: English and Français. The choice is saved in a cookie that pickLocale reads first. The buttons call a Server Action:
app/[lang]/actions.ts"use server"; import { cookies } from "next/headers";import { redirect } from "next/navigation";import { isLocale } from "../../i18n"; export async function chooseLanguage(formData: FormData) { const lang = String(formData.get("lang")); if (!isLocale(lang)) return; (await cookies()).set("lang", lang, { maxAge: 60 * 60 * 24 * 365 }); redirect(`/${lang}`);}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Du thé frais, envoyé chaque semaine.
Nos thés- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (4), without the files it loaded
GET/307a whole pageGET/fr200a whole pageGET/fr/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /fr
GET/fr/teas200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /$d$lang/teas/__PAGE__
- next-url
- /fr
In app/[lang]/actions.ts, line 8 reads which button was pressed. Line 9 ignores any value that isn't a language the shop has, because a Server Action can be called with anything. Line 10 sets the cookie lang for a year (60 * 60 * 24 * 365 seconds), and line 11 sends the visitor to the home page in that language. The cookie only stores the visitor's own preference in their own browser, so there's nothing to protect here.
The Steps tab follows Chrome. It opened / with no cookie, and proxy.ts sent it to /en. It pressed Français: the action ran as a POST to /en, and the router showed /fr. Then it opened / again, as someone typing the address the next day: GET / answered 307, and Chrome landed on /fr. The cookie won over the browser's languages. The Network tab shows the same form posted without JavaScript: 303 to /fr, with set-cookie: lang=fr and Max-Age=31536000.
Another design saves fr whenever proxy.ts sees an address starting with /fr; next-intl's docs, for one, say it updates its cookie when a visitor opens an address with a language in it. This shop chose a Server Action as a safeguard: proxy.ts also runs for the router's prefetches (the proxy.ts lesson recorded them), and a page with a link to the French home page prefetches it as soon as the link comes on screen. A Server Action runs only when the visitor presses the button, so here the cookie only ever holds a choice they made.
Loading dictionaries on the server
Here is how a page gets its words:
import "server-only";import type { Locale } from "../../i18n"; const dictionaries = { en: () => import("./dictionaries/en.json").then((module) => module.default), fr: () => import("./dictionaries/fr.json").then((module) => module.default),}; export async function getDictionary(lang: Locale) { return dictionaries[lang]();} Line 1, import "server-only", makes the build fail if a Client Component imports this file (Server-only code shows the error). Lines 5 and 6 load each dictionary with import(), which reads a file only when it's called, so a French page loads fr.json and never en.json. Line 10 calls the one for lang. TypeScript knows that lang is "en" or "fr", so a missing dictionary would be a type error.
The teas page uses the words in a Server Component and in a Client Component, and formats the prices for each language:
app/[lang]/teas/page.tsximport { notFound } from "next/navigation";import { isLocale } from "../../../i18n";import { getDictionary } from "../dictionaries";import AddToCart from "./add-to-cart"; export default async function TeasPage({ params }: PageProps<"/[lang]/teas">) { const { lang } = await params; if (!isLocale(lang)) notFound(); const words = await getDictionary(lang); const price = new Intl.NumberFormat(lang, { style: "currency", currency: "EUR" }); return ( <main> <h1>{words.teas}</h1> <ul> <li> {words.green}: {price.format(4)} <AddToCart add={words.add} added={words.added} /> </li> <li> {words.mint}: {price.format(3.5)} <AddToCart add={words.add} added={words.added} /> </li> </ul> </main> );}- Thé vert: 4,00 €
- Thé à la menthe: 3,50 €
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Line 9 loads the words. Line 10 makes a price formatter with Intl.NumberFormat for the page's language: €4.00 in English and 4,00 € in French, from the same number. Line 16 passes the button's two labels to AddToCart, a Client Component, as props. The Browser tab shows Thé vert: 4,00 € Ajouter au panier.
The recorder also searched the JavaScript each page loads for Ajouter au panier, Add to cart, Thé à la menthe and Mint tea. It found none of them, on either page. The dictionaries stay on the server. The words a page uses reach the browser in that page's HTML and its RSC payload: the description of what the Server Components rendered, which includes the props for AddToCart.
Here every page passes lang to getDictionary. Since Next.js 16.3, next/root-params lets any Server Component read a segment above the root layout, like lang, without passing it down. The Multi-tenant apps lesson covers these root params.
Prerendering every language
Line 4 of the layout exports generateStaticParams, which returns one { lang } for each language in locales. Because it's in the root layout, every page under [lang] is made for both. Try the layout without it:
app/[lang]/layout.tsximport { notFound } from "next/navigation";import { isLocale, locales } from "../../i18n"; export function generateStaticParams() { return locales.map((lang) => ({ lang }));} export default async function RootLayout({ children, params }: LayoutProps<"/[lang]">) { const { lang } = await params; if (!isLocale(lang)) notFound(); return ( <html lang={lang}> <body>{children}</body> </html> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (8/8) Finalizing page optimization ... Route (app)┌ ○ /_not-found├ /[lang]│ ├ ◐ /[lang]│ ├ ○ /en│ └ ○ /fr└ /[lang]/teas ├ ◐ /[lang]/teas ├ ○ /en/teas └ ○ /fr/teas ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/[lang].html 0 bytes.next/server/app/[lang].meta 1.8 kB.next/server/app/[lang].segments/$d$lang/__PAGE__.segment.rsc 2.6 kB.next/server/app/[lang].segments/_full.segment.rsc 2.9 kB.next/server/app/[lang].segments/_tree.segment.rsc 195 bytes.next/server/app/[lang]/teas.html 0 bytes.next/server/app/[lang]/teas.meta 1.9 kB.next/server/app/[lang]/teas.segments/$d$lang/teas/__PAGE__.segment.rsc 2.8 kB.next/server/app/[lang]/teas.segments/_full.segment.rsc 3.3 kB.next/server/app/[lang]/teas.segments/_tree.segment.rsc 245 bytes.next/server/app/_global-error.html 9.2 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.3 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.3 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.3 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.8 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/en.html 5.8 kB.next/server/app/en.meta 743 bytes.next/server/app/en.rsc 3.5 kB.next/server/app/en.segments/$d$lang/__PAGE__.segment.rsc 3.0 kB.next/server/app/en.segments/_full.segment.rsc 3.5 kB.next/server/app/en.segments/_tree.segment.rsc 195 bytes.next/server/app/en/teas.html 6.7 kB.next/server/app/en/teas.meta 910 bytes.next/server/app/en/teas.rsc 4.1 kB.next/server/app/en/teas.segments/$d$lang/teas/__PAGE__.segment.rsc 3.4 kB.next/server/app/en/teas.segments/_full.segment.rsc 4.1 kB.next/server/app/en/teas.segments/_tree.segment.rsc 245 bytes.next/server/app/fr.html 5.9 kB.next/server/app/fr.meta 743 bytes.next/server/app/fr.rsc 3.6 kB.next/server/app/fr.segments/$d$lang/__PAGE__.segment.rsc 3.0 kB.next/server/app/fr.segments/_full.segment.rsc 3.6 kB.next/server/app/fr.segments/_tree.segment.rsc 195 bytes.next/server/app/fr/teas.html 6.7 kB.next/server/app/fr/teas.meta 910 bytes.next/server/app/fr/teas.rsc 4.1 kB.next/server/app/fr/teas.segments/$d$lang/teas/__PAGE__.segment.rsc 3.4 kB.next/server/app/fr/teas.segments/_full.segment.rsc 4.1 kB.next/server/app/fr/teas.segments/_tree.segment.rsc 245 bytesLines 4 to 6 return one value for each language in locales. The build prerenders every page for both: ○ /en, ○ /fr, ○ /en/teas and ○ /fr/teas, and ◐ shells for any other value.
With the list, the build prints ○ /en, ○ /fr, ○ /en/teas and ○ /fr/teas: prerendered as static content. ◐ /[lang] and ◐ /[lang]/teas are the shells Next.js keeps for any value the list doesn't name. Without the list, the build stops: A required root parameter (lang) was not provided in generateStaticParams for /[lang]/teas, please provide at least one value. With Cache Components on, a dynamic segment above the root layout must have at least one value during the build.
generateStaticParams returns one value per language, and next build prerenders every page under [lang] for each: ○. ◐ /[lang] is the shell for a value the list doesn't name.The built files show it too. This build of the shop lists the files next build wrote in .next/server/app; the names are this build's, not a promise. en.html and fr.html are the two home pages, and only fr.html holds Boutique de thé. The Network tab says that /en was sent as en.html, whole, and /fr/teas as fr/teas.html:
app/[lang]/layout.tsximport { notFound } from "next/navigation";import { isLocale, locales } from "../../i18n"; export function generateStaticParams() { return locales.map((lang) => ({ lang }));} export default async function RootLayout({ children, params }: LayoutProps<"/[lang]">) { const { lang } = await params; if (!isLocale(lang)) notFound(); return ( <html lang={lang}> <body>{children}</body> </html> );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (8/8) Finalizing page optimization ... Route (app)┌ ○ /_not-found├ /[lang]│ ├ ◐ /[lang]│ ├ ○ /en│ └ ○ /fr└ /[lang]/teas ├ ◐ /[lang]/teas ├ ○ /en/teas └ ○ /fr/teas ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content # The files next build wrote for the prerendered pages, in .next/server/app# (the server's .js code and the .json lists left out), with their sizes.next/server/app/[lang].html 0 bytes.next/server/app/[lang].meta 1.8 kB.next/server/app/[lang].segments/$d$lang/__PAGE__.segment.rsc 2.6 kB.next/server/app/[lang].segments/_full.segment.rsc 2.9 kB.next/server/app/[lang].segments/_tree.segment.rsc 195 bytes.next/server/app/[lang]/teas.html 0 bytes.next/server/app/[lang]/teas.meta 1.9 kB.next/server/app/[lang]/teas.segments/$d$lang/teas/__PAGE__.segment.rsc 2.8 kB.next/server/app/[lang]/teas.segments/_full.segment.rsc 3.3 kB.next/server/app/[lang]/teas.segments/_tree.segment.rsc 245 bytes.next/server/app/_global-error.html 9.2 kB.next/server/app/_global-error.meta 681 bytes.next/server/app/_global-error.rsc 4.6 kB.next/server/app/_global-error.segments/__PAGE__.segment.rsc 4.3 kB.next/server/app/_global-error.segments/_full.segment.rsc 4.6 kB.next/server/app/_global-error.segments/_tree.segment.rsc 115 bytes.next/server/app/_not-found.html 7.3 kB.next/server/app/_not-found.meta 774 bytes.next/server/app/_not-found.rsc 4.3 kB.next/server/app/_not-found.segments/_full.segment.rsc 4.3 kB.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc 3.8 kB.next/server/app/_not-found.segments/_tree.segment.rsc 172 bytes.next/server/app/en.html 5.8 kB.next/server/app/en.meta 743 bytes.next/server/app/en.rsc 3.5 kB.next/server/app/en.segments/$d$lang/__PAGE__.segment.rsc 3.0 kB.next/server/app/en.segments/_full.segment.rsc 3.5 kB.next/server/app/en.segments/_tree.segment.rsc 195 bytes.next/server/app/en/teas.html 6.7 kB.next/server/app/en/teas.meta 910 bytes.next/server/app/en/teas.rsc 4.1 kB.next/server/app/en/teas.segments/$d$lang/teas/__PAGE__.segment.rsc 3.4 kB.next/server/app/en/teas.segments/_full.segment.rsc 4.1 kB.next/server/app/en/teas.segments/_tree.segment.rsc 245 bytes.next/server/app/fr.html 5.9 kB.next/server/app/fr.meta 743 bytes.next/server/app/fr.rsc 3.6 kB.next/server/app/fr.segments/$d$lang/__PAGE__.segment.rsc 3.0 kB.next/server/app/fr.segments/_full.segment.rsc 3.6 kB.next/server/app/fr.segments/_tree.segment.rsc 195 bytes.next/server/app/fr/teas.html 6.7 kB.next/server/app/fr/teas.meta 910 bytes.next/server/app/fr/teas.rsc 4.1 kB.next/server/app/fr/teas.segments/$d$lang/teas/__PAGE__.segment.rsc 3.4 kB.next/server/app/fr/teas.segments/_full.segment.rsc 4.1 kB.next/server/app/fr/teas.segments/_tree.segment.rsc 245 bytesTelling search engines about each language
/en and /fr are the same page in two languages. Search engines can show each visitor the right one, if every version lists the others. That's a <link rel="alternate" hreflang> tag in the head, one per language, and the Metadata API writes them from alternates:
app/[lang]/page.tsximport type { Metadata } from "next";import Link from "next/link";import { notFound } from "next/navigation";import { isLocale, locales } from "../../i18n";import { getDictionary } from "./dictionaries"; export async function generateMetadata({ params }: PageProps<"/[lang]">): Promise<Metadata> { const { lang } = await params; if (!isLocale(lang)) return {}; const words = await getDictionary(lang); return { metadataBase: new URL("https://tea-shop.example"), title: words.title, alternates: { canonical: `/${lang}`, languages: { ...Object.fromEntries(locales.map((code) => [code, `/${code}`])), "x-default": "/", }, }, };} export default async function Home({ params }: PageProps<"/[lang]">) { const { lang } = await params; if (!isLocale(lang)) notFound(); const words = await getDictionary(lang); return ( <main> <h1>{words.title}</h1> <p>{words.intro}</p> <Link href={`/${lang}/teas`}>{words.teas}</Link> </main> );}GET /fr200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 18 scripts left out
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/06bs-nf19b2js.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/3l_qmp-61z4yc.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<script src="/_next/static/chunks/21poqdgvjdll4.js" async=""></script>
<title>Boutique de thé</title>
<link rel="canonical" href="https://tea-shop.example/fr">
<link rel="alternate" hreflang="en" href="https://tea-shop.example/en">
<link rel="alternate" hreflang="fr" href="https://tea-shop.example/fr">
<link rel="alternate" hreflang="x-default" href="https://tea-shop.example">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<main>
<h1>Boutique de thé</h1>
<p>Du thé frais, envoyé chaque semaine.</p>
<a href="/fr/teas">Nos thés</a>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>In app/[lang]/page.tsx, line 12 sets metadataBase, the site's own address, so Next.js can write full addresses. Line 13 takes the title from the dictionary. Line 15 says which address is this page's own. Lines 16 to 19 list one address for each language in locales, built on line 17, and line 18 adds x-default. x-default is the address for visitors whose language isn't listed: /, where proxy.ts picks one. The Network tab shows the head of /fr:
<html lang="fr">and<title>Boutique de thé</title>;<link rel="canonical" href="https://tea-shop.example/fr">;hreflang="en",hreflang="fr"andhreflang="x-default", each with its full address.
Each page lists its own versions: the teas page would list /en/teas and /fr/teas. Put alternates in each page, not in the layout, where every page would claim the home page's addresses.
next-intl and other libraries
Everything so far is your own code, a few dozen lines. Next.js's docs also list libraries that do it for you. One of them is next-intl. On October 30, 2026, its latest version on npm is 4.14.9, and its package.json accepts Next.js 16 as a peer. Its docs set it up with a proxy.ts, and its blog post on Next.js 16.3 describes reading the language with next/root-params, which improves how it works with Cache Components. No example here is built with it. Each row compares one job, as next-intl's docs describe it:
| Job | The shop's own code | next-intl |
|---|---|---|
| Choosing the language | pickLocale in i18n.ts, called from proxy.ts. | createMiddleware(routing) from next-intl/middleware, exported from proxy.ts. |
| Remembering a choice | A Server Action that sets the lang cookie. | A cookie of its own, which it checks after the address and before Accept-Language. |
| The words | JSON dictionaries, read with getDictionary(lang). | Message files, read with useTranslations or await getTranslations(). |
| Plurals, numbers and dates | Intl.NumberFormat and friends, by hand. | Messages in ICU syntax, and formatting helpers like useFormatter. |
| Links in the visitor's language | Written out: /${lang}/teas. | Link and redirect from createNavigation(routing), which add the language for you. |
A library helps most when the shop grows: plurals (1 tea, 2 teas), dates, many translators, or languages whose rules differ. For two languages and a few dozen words, the shop's own code is enough, and every line of it is yours to read.
Adding a third language
The shop adds German. In the finished shop, proxy.ts, the switcher's buttons, generateStaticParams and the hreflang links all read locales, so the change is small: "de" on line 1 of i18n.ts and its name, Deutsch, on line 4, a de.json, and one line in dictionaries.ts. TypeScript checks the last two: languageNames and dictionaries must have an entry for every language. Here is the whole shop with German:
i18n.tsexport const locales = ["en", "fr", "de"] as const;export type Locale = (typeof locales)[number];export const defaultLocale: Locale = "en";export const languageNames: Record<Locale, string> = { en: "English", fr: "Français", de: "Deutsch" }; export function isLocale(value: string | undefined): value is Locale { return locales.includes(value as Locale);} // The languages an Accept-Language header asks for, best first:// "fr-CA,fr;q=0.9,en;q=0.8" gives ["fr-ca", "fr", "en"].export function preferredLanguages(header: string) { return header .split(",") .map((part) => { const [tag, ...params] = part.split(";").map((piece) => piece.trim()); const weight = params.find((param) => /^q=/i.test(param)); return { tag: tag.toLowerCase(), q: weight === undefined ? 1 : Number(weight.slice(2)) }; }) // A weight goes from 0 to 1. 0 means "not this one", and anything else isn't a weight. .filter((item) => item.tag && item.q > 0 && item.q <= 1) .sort((a, b) => b.q - a.q) .map((item) => item.tag);} // The saved choice first, then the browser's languages in order, then English.export function pickLocale(acceptLanguage: string, saved?: string): Locale { if (isLocale(saved)) return saved; for (const tag of preferredLanguages(acceptLanguage)) { const language = tag.split("-")[0]; if (isLocale(language)) return language; } return defaultLocale;}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (10/10) Finalizing page optimization ... Route (app)┌ ○ /_not-found├ /[lang]│ ├ ◐ /[lang]│ ├ ○ /en│ ├ ○ /fr│ └ ○ /de└ /[lang]/teas ├ ◐ /[lang]/teas ├ ○ /en/teas ├ ○ /fr/teas └ ○ /de/teas ƒ Proxy (Middleware) ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentThe Terminal tab lists ○ /de and ○ /de/teas next to the others. A German browser asking for / now goes to /de. In the Network tab, /de has three buttons, English Français Deutsch, and its head lists hreflang="de" next to en, fr and x-default. /de/teas shows Grüner Tee: 4,00 € In den Warenkorb. Sort which files changed:
- The
localeslist ini18n.ts languageNamesini18n.ts- A new file,
dictionaries/de.json - The
dictionariesobject indictionaries.ts app/[lang]/teas/page.tsxproxy.tsgenerateStaticParamsin the layout- The language buttons in the layout
alternates.languageson the home page
Sort each file or setting: does it change for German, or stay the same?
Common mix-ups
- "Next.js translates pages for you." No. It routes and renders each language. The words come from your dictionaries, or a library's message files.
- "The browser's language should always win." No. It's a guess. A choice the visitor made, saved in a cookie, comes first.
- "Dictionaries make the JavaScript bigger." No. Loaded in Server Components, they never reach the browser's JavaScript; the recording found none of their words there.
- "The language needs its own request at run time." No. With
generateStaticParams, every language is prerendered:○ /en,○ /fr. - "Any value in [lang] is fine." No.
/dereaches the layout too. Check it, and answer 404. - "hreflang tags redirect search engines." No. They only list the other versions of the page.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
A browser with no lang cookie asks for / with fr-CA,fr;q=0.9,en;q=0.8. Where does proxy.ts send it?
export const locales = ["en", "fr"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const languageNames: Record<Locale, string> = { en: "English", fr: "Français" };
export function isLocale(value: string | undefined): value is Locale {
return locales.includes(value as Locale);
}
// The languages an Accept-Language header asks for, best first:
// "fr-CA,fr;q=0.9,en;q=0.8" gives ["fr-ca", "fr", "en"].
export function preferredLanguages(header: string) {
return header
.split(",")
.map((part) => {
const [tag, ...params] = part.split(";").map((piece) => piece.trim());
const weight = params.find((param) => /^q=/i.test(param));
return { tag: tag.toLowerCase(), q: weight === undefined ? 1 : Number(weight.slice(2)) };
})
// A weight goes from 0 to 1. 0 means "not this one", and anything else isn't a weight.
.filter((item) => item.tag && item.q > 0 && item.q <= 1)
.sort((a, b) => b.q - a.q)
.map((item) => item.tag);
}
// The saved choice first, then the browser's languages in order, then English.
export function pickLocale(acceptLanguage: string, saved?: string): Locale {
if (isLocale(saved)) return saved;
for (const tag of preferredLanguages(acceptLanguage)) {
const language = tag.split("-")[0];
if (isLocale(language)) return language;
}
return defaultLocale;
}
/fr. The first language is fr-ca, whose language part, fr, is one the shop has. proxy.ts answers 307 with location: /fr, as the first recorded request shows.
The same browser now also sends the cookie lang=en. Where does proxy.ts send it?
/en. The saved choice comes first, so the French header is never read. The recording answered 307 to /en.
What does this root layout write in the lang attribute of /fr/teas?
import { notFound } from "next/navigation";
import { isLocale, locales } from "../../i18n";
export function generateStaticParams() {
return locales.map((lang) => ({ lang }));
}
export default async function RootLayout({ children, params }: LayoutProps<"/[lang]">) {
const { lang } = await params;
if (!isLocale(lang)) notFound();
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}
fr. The [lang] place of /fr/teas holds fr, and line 12 writes it into <html lang>. The recorded HTML starts with <html lang="fr">.
A teammate swapped the shop's matcher for this one. An English browser asks for /leaf.svg, a picture in public. Where does it end up?
import { NextResponse, type NextRequest } from "next/server";
import { locales, pickLocale } from "./i18n";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = locales.some((lang) => pathname === `/${lang}` || pathname.startsWith(`/${lang}/`));
if (hasLocale) return NextResponse.next();
const lang = pickLocale(request.headers.get("accept-language") ?? "", request.cookies.get("lang")?.value);
request.nextUrl.pathname = `/${lang}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)"],
};
import { NextResponse, type NextRequest } from "next/server";
import { locales, pickLocale } from "./i18n";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = locales.some((lang) => pathname === `/${lang}` || pathname.startsWith(`/${lang}/`));
if (hasLocale) return NextResponse.next();
const lang = pickLocale(request.headers.get("accept-language") ?? "", request.cookies.get("lang")?.value);
request.nextUrl.pathname = `/${lang}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"],
};
/en/leaf.svg, a page that doesn't exist. This matcher, the one the proxy.ts lesson started from, skips only the files it names, so proxy.ts redirects the picture. The recording answered 307 with location: /en/leaf.svg. The shop's matcher skips any path with a dot.
With this layout, next build stops: A required root parameter (lang) was not provided in generateStaticParams. What's missing?
import { notFound } from "next/navigation";
import { isLocale } from "../../i18n";
export default async function RootLayout({ children, params }: LayoutProps<"/[lang]">) {
const { lang } = await params;
if (!isLocale(lang)) notFound();
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}
import { notFound } from "next/navigation";
import { isLocale, locales } from "../../i18n";
export function generateStaticParams() {
return locales.map((lang) => ({ lang }));
}
export default async function RootLayout({ children, params }: LayoutProps<"/[lang]">) {
const { lang } = await params;
if (!isLocale(lang)) notFound();
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}
generateStaticParams, returning locales.map((lang) => ({ lang })). With Cache Components, the build needs at least one value for [lang]. With it back, the recorded build lists ○ /en and ○ /fr.
You add "de" to locales, with a de.json. Which two new lines does next build print?
○ /de and ○ /de/teas. Adding de to locales adds a value to generateStaticParams, and the build prerenders both pages for it. The recorded build lists them after the English and French ones.
Check your understanding
For each question, ask where the language comes from: the address, a cookie, or the browser's header.
Question 1 of 7A visitor's browser sends
Accept-Language: de-DE,de;q=0.9,fr;q=0.5for/. Where does this proxy.ts send them?Read the code, then predictTypeScriptimport { NextResponse, type NextRequest } from "next/server"; import { locales, pickLocale } from "./i18n"; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const hasLocale = locales.some((lang) => pathname === `/${lang}` || pathname.startsWith(`/${lang}/`)); if (hasLocale) return NextResponse.next(); const lang = pickLocale(request.headers.get("accept-language") ?? "", request.cookies.get("lang")?.value); request.nextUrl.pathname = `/${lang}${pathname}`; return NextResponse.redirect(request.nextUrl); } export const config = { matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"], };Choose an answer to see the explanation.
Question 2 of 7The cookie
lang=enand the headerfr-CA,fr;q=0.9arrive together at/. Which wins?Choose an answer to see the explanation.
Question 3 of 7Why does the shop save the language choice in a Server Action, and not in proxy.ts for every
/fraddress?Choose an answer to see the explanation.
Question 4 of 7This layout has no
generateStaticParams, and Cache Components is on. What doesnext builddo?Read the code, then predictTSXimport { notFound } from "next/navigation"; import { isLocale } from "../../i18n"; export default async function RootLayout({ children, params }: LayoutProps<"/[lang]">) { const { lang } = await params; if (!isLocale(lang)) notFound(); return ( <html lang={lang}> <body>{children}</body> </html> ); }Choose an answer to see the explanation.
Question 5 of 7The teas page passes
words.addto a Client Component. Does the browser download the French dictionary?Choose an answer to see the explanation.
Question 6 of 7What does
alternates.languagesadd to the home page in French?Choose an answer to see the explanation.
Question 7 of 7A visitor opens
/dein the overview's shop, which has no proxy.ts. What happens?Choose an answer to see the explanation.
Key takeaways
- Put every page under
app/[lang], with the root layout there too, so<html lang>gets the language. Checklangand answer 404 for one you don't have. - In proxy.ts, send addresses without a language to the visitor's: a saved cookie first, then
Accept-Languagein order of weight, then a default. Leave files with a dot out of the matcher. - Save the visitor's own choice in a cookie from a Server Action, which only runs when they choose.
- Load dictionaries in Server Components, behind
import "server-only": none of their words reach the JavaScript. Client Components get their words as props. generateStaticParamsin the root layout prerenders every language. With Cache Components, the build needs it.- List each page's other languages with
alternates.languages; next-intl can do much of this for you when the app grows.
Remember the one-liner.
The language lives in the address; proxy.ts picks it for visitors who don't give one, and the server fills each page with its words.
Coming next: Multi-tenant apps. It serves many shops from one app, each on its own domain, with proxy.ts rewriting to the right one.