i18n Routing & Locales
[locale] dynamic segment that every route lives under, plus middleware that decides which locale a visitor should see when they haven't picked one explicitly.The [locale] segment pattern
Folder structure
app/
[locale]/
layout.tsx // reads params.locale, sets up translations
page.tsx // home page for /en or /de
about/
page.tsx // /en/about or /de/about[locale] automatically has access to the current locale via params.locale, which is how a shared layout knows which language to render its chrome (nav, footer) in.app/[locale]/layout.tsx
export function generateStaticParams() {
return [{ locale: 'en' }, { locale: 'de' }]
}
export default function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: { locale: string }
}) {
return (
<html lang={params.locale}>
<body>{children}</body>
</html>
)
}Detecting the preferred locale in middleware
/ with no locale in the URL, middleware is where you decide which locale to redirect them to — typically by reading the existing locale cookie if one was set on a previous visit, and falling back to the browser's Accept-Language header otherwise.middleware.ts — conceptual shape
import { NextRequest, NextResponse } from 'next/server'
const locales = ['en', 'de']
const defaultLocale = 'en'
function getPreferredLocale(request: NextRequest) {
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value
if (cookieLocale && locales.includes(cookieLocale)) return cookieLocale
const header = request.headers.get('accept-language') ?? ''
const preferred = header.split(',')[0]?.split('-')[0]
return locales.includes(preferred) ? preferred : defaultLocale
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}`),
)
if (hasLocale) return NextResponse.next()
const locale = getPreferredLocale(request)
const url = request.nextUrl.clone()
url.pathname = `/${locale}${pathname}`
return NextResponse.redirect(url)
}
export const config = {
matcher: ['/((?!_next|api|.*\\..*).*)'],
}createMiddleware helper that implements this detection logic (plus cookie persistence, locale prefix stripping, and more) — most real projects use that instead of hand-rolling detection from scratch.The localePrefix trade-off
| URL shape | Trade-off |
|---|---|---|
|
| Every locale is explicit and equally discoverable, but the default locale gets a slightly longer URL |
|
| Cleanest URLs for the default locale, but locale detection has to special-case the unprefixed path |
|
| Shortest, most stable URLs; loses shareable-per-language links entirely |
localePrefix: 'never' — the URL you're on right now doesn't encode the locale at all, it's resolved from a cookie. That's a deliberate choice for a docs-style site where locale is a personal preference rather than something worth bookmarking separately per language.A
[locale]dynamic segment is the standard App Router pattern for locale-aware routing.Middleware decides the locale for un-prefixed requests, usually via a cookie first, then
Accept-Language.localePrefixcontrols whether the locale appears in the URL at all, and for which locales — always/as-needed/never.Most projects use a library (e.g. next-intl) to implement this pattern rather than hand-rolling it.