Locale detection in Next.js App Router is something you assemble rather than switch on. The i18n config option that older tutorials reference belongs to the Pages Router and does nothing here.
Three pieces do the work. A dynamic segment carries the locale in the URL, the proxy file picks a locale when the URL has none, and dictionaries turn that locale into translated strings.
Start with the route shape. Every route lives under a dynamic segment so the locale is part of the path.
// app/[lang]/page.tsx
export default async function Page({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params
return <p>Locale: {lang}</p>
}A request to /nl renders with the locale set to nl, and /en-US renders with that. Route params are asynchronous in current Next.js, so the value has to be awaited before it can be read.
Detecting the visitor's language
The browser announces preferences in the Accept-Language header, which is a weighted list rather than a single value. Parsing it correctly is a solved problem, so use the two packages the Next.js guide points at.
npm install negotiator @formatjs/intl-localematcher
npm install --save-dev @types/negotiatorOne package turns the header into an ordered list of languages, and the other picks the best match from the locales you actually support. Neither is a Next.js package, so their behavior is the same anywhere.
// lib/locale.ts
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
export const locales = ['en-US', 'nl']
export function getLocale(header: string | null) {
const languages = new Negotiator({ headers: { 'accept-language': header ?? '' } }).languages()
return match(languages, locales, 'en-US')
}A browser sending a Dutch preference resolves to nl, and anything unrecognized falls back to the default. Keeping this in its own module means the proxy file stays short and the matching logic can be tested on its own.
The same module is a good home for the opposite question: does this path already name a locale? Answering it in one place keeps the proxy file from carrying string comparisons.
// lib/locale.ts
export function pathHasLocale(pathname: string) {
return locales.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`))
}This matches both a bare locale path and anything nested under one, so /nl and /nl/products both count as already localized.
Redirecting to a locale prefix
Now the proxy file connects the two. When a request already carries a supported locale it passes through, and when it does not, the visitor is redirected to the detected one.
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getLocale, pathHasLocale } from './lib/locale'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathHasLocale(pathname)) return NextResponse.next()
request.nextUrl.pathname = `/${getLocale(request.headers.get('accept-language'))}${pathname}`
return NextResponse.redirect(request.nextUrl)
}A Dutch browser requesting the site root receives a 307 to /nl, and an English one is sent to /en-US. A request that already reads /nl/products is left alone, which is what stops the rule from firing forever.
The matcher matters as much as the function here, because this rule rewrites the path of everything it touches. Excluding the internal build paths keeps locale prefixes off scripts and stylesheets.
// proxy.ts
export const config = { matcher: '/((?!_next).*)' }Without that exclusion, a request for a build asset would be redirected to a locale-prefixed copy of itself that does not exist, and the page would load without styles or JavaScript.
The diagram shows why this costs an extra round trip for first-time visitors. Anyone arriving on a locale URL directly, including from a search result or a shared link, skips the redirect entirely.
The chosen locale comes from the visitor's browser, so a permanent redirect would be cached and then applied to everyone using that browser. Use a temporary redirect here, which is what the redirect helper produces by default.
The unsupported locale trap
The check above only recognizes locales you listed. A request for a locale you do not support is treated as an ordinary path segment, so it gets a prefix added in front of it.
Requesting /de on a site that supports only English and Dutch produces a redirect to /en-US/de, which then fails to match a route. The symptom looks like a broken redirect, but the cause is a missing locale.
Decide what should happen for those requests. Serving the default locale's homepage is friendlier than a 404, and either way the choice should be deliberate rather than accidental.
The same applies to any path that looks like a locale but is not one. A marketing route named after a country code, or an old two-letter path from a previous site structure, will collide with this rule in exactly the same way.
Checking the segment against your supported list before using it is the habit that catches all of these. It also protects the dictionary lookup further down, where an unrecognized key would otherwise throw at render time.
Reading the locale deeper in the tree
Passing the locale down through props works until a utility five levels deep needs it. Next.js 16.3 added the next/root-params module, so any Server Component or server-side utility can read the segment above the root layout without receiving it as an argument.
// app/[lang]/dictionaries.ts
const dictionaries = {
'en-US': () => import('./dictionaries/en.json').then((m) => m.default),
nl: () => import('./dictionaries/nl.json').then((m) => m.default),
}Each entry is a function rather than a value, so only the requested translation file is loaded. The getter that uses this map reads the locale itself instead of accepting it as an argument.
// app/[lang]/dictionaries.ts
import { lang } from 'next/root-params'
import { notFound } from 'next/navigation'
export async function getDictionary() {
const locale = await lang()
if (!locale || !(locale in dictionaries)) notFound()
return dictionaries[locale as keyof typeof dictionaries]()
}The getter is named after the dynamic segment, so a segment called lang produces a lang getter. Callers now ask for the dictionary with no arguments, and an unknown locale produces a 404 instead of a runtime crash.
Dictionaries load on the server only, so translation files never reach the browser bundle. A page just awaits the dictionary and renders the strings.
// app/[lang]/page.tsx
import { getDictionary } from './dictionaries'
export default async function Page() {
const dict = await getDictionary()
return <button type="button">{dict.products.cart}</button>
}The button renders in the visitor's language, and nothing about the component knows which locale it is. Root params covers the getter API and where it can be called, which notably excludes Client Components and Route Handlers.
Keeping locales prerendered
A dynamic segment does not force request-time rendering. Listing the locales you support lets Next.js build each one ahead of time.
// app/[lang]/layout.tsx
export async function generateStaticParams() {
return [{ lang: 'en-US' }, { lang: 'nl' }]
}The build now emits prerendered routes for both locales rather than rendering them per request. Putting this in the root layout covers every page nested under it.
Set the document language from the same value so assistive technology announces the page correctly. The layout already receives the params, so it is the natural place for it.
// app/[lang]/layout.tsx
export default async function RootLayout({ children, params }: { children: React.ReactNode, params: Promise<{ lang: string }> }) {
const { lang } = await params
return <html lang={lang}><body>{children}</body></html>
}The rendered document carries a language attribute matching the URL, which screen readers use to select pronunciation. generateStaticParams covers prerendering dynamic segments in more depth.
Common mistakes
Most locale routing problems come from a handful of causes rather than from the detection logic itself.
- Reaching for the config i18n option, which the App Router ignores.
- Forgetting to exclude internal paths in the matcher, so build assets get a locale prefix.
- Making the locale redirect permanent, which freezes one visitor's language for everyone after them.
- Trusting the segment value without checking it against your supported list.
If a locale switcher is on the roadmap, store the visitor's explicit choice in a cookie and check that before the header. Redirecting based on cookies and headers covers combining those two signals in one rule.
Rune AI
Key Insights
- The App Router has no i18n config option; that feature belongs to the Pages Router.
- Nest routes under a dynamic segment so every layout and page receives the locale.
- Detect the preferred locale from the Accept-Language header with negotiator and a locale matcher.
- Redirect to a locale prefix in the proxy file, and skip requests that already carry one.
- Read the locale deeper in the tree with next/root-params instead of prop drilling.
Frequently Asked Questions
Can I use the i18n config from next.config in the App Router?
Why does requesting an unsupported locale produce a doubled path?
Should the locale redirect be permanent?
Can I read the locale in a Route Handler with root params?
Conclusion
Locale routing in the App Router is three pieces that fit together: a dynamic segment that carries the locale, a proxy redirect that picks one from the request when the URL has none, and generateStaticParams so the known locales still prerender. Keep the redirect temporary and validate the locale before using it.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.