Nextjsi18n Routing & Locales

i18n Routing & Locales

Locale-aware routing in the App Router is built from two ordinary pieces you already know: a dynamic route segment, and middleware. The convention that's emerged (and that libraries like next-intl implement for you) is a top-level [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

Text
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
Every route under [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

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
When a visitor requests / 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

TS
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|.*\\..*).*)'],
}
Note
Libraries like next-intl provide a ready-made 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

localePrefix setting

URL shape

Trade-off

always

/en/about, /de/about

Every locale is explicit and equally discoverable, but the default locale gets a slightly longer URL

as-needed

/about (default locale), /de/about (others)

Cleanest URLs for the default locale, but locale detection has to special-case the unprefixed path

never

/about for every locale, locale resolved from a cookie only

Shortest, most stable URLs; loses shareable-per-language links entirely

Note
This site uses 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.

  • localePrefix controls 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.