Internationalized Routing and Locale Detection in Next.js

The App Router has no built-in i18n config. Build locale routing yourself with a dynamic segment, a proxy redirect that reads Accept-Language, and prerendered locales.

10 min read

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.tsxApp.tsx
// 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.

bashbash
npm install negotiator @formatjs/intl-localematcher
npm install --save-dev @types/negotiator

One 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.

typescripttypescript
// 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.

typescripttypescript
// 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.

typescripttypescript
// 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.

typescripttypescript
// 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.

Locale redirect on a request without a prefix

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.

Keep this redirect temporary

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.

typescripttypescript
// 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.

typescripttypescript
// 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.tsxApp.tsx
// 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.tsxApp.tsx
// 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.tsxApp.tsx
// 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Can I use the i18n config from next.config in the App Router?

No. The i18n option and its locale properties belong to the Pages Router and are not available in the App Router. Locale routing there is built from a dynamic segment plus your own detection logic.

Why does requesting an unsupported locale produce a doubled path?

Because the detection check only recognizes locales you listed. A request for a locale you do not support is treated as an ordinary path, so it gets a locale prefix added in front of it and then fails to match a route.

Should the locale redirect be permanent?

No. The chosen locale depends on the visitor's browser preferences, and a 308 would be cached and applied to everyone using that browser afterwards. Keep it temporary.

Can I read the locale in a Route Handler with root params?

No. Root parameter getters run in Server Components and server-side utilities, but not in Client Components, Server Actions, or Route Handlers. Read the segment from the URL in those places instead.

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.