Sub-path vs domain routing, locale detection via Accept-Language in middleware, next-intl with server-side getTranslations, locale-aware generateStaticParams, and hreflang SEO.
P-12 — Internationalisation (i18n) Routing
Who this is for: Engineers about to add multi-language support to a Next.js App Router project who have discovered that the App Router removed the built-in i18n routing from the Pages Router and are now figuring out how to implement it properly. This module covers the full pattern — routing, translation, locale detection, SEO, and RTL — without leaning on magic.
Two Routing Strategies
There are two standard approaches to i18n routing, and the choice affects your URL structure, deployment complexity, and CDN configuration.
Sub-path routing puts the locale in the URL path: /en/about, /fr/about, /de/about. All locales live on the same domain and the same deployment. This is simpler to set up, simpler to deploy, and simpler to reason about. Switching locales is a URL change. CDN caching works normally. It's what most applications should use.
Domain routing uses separate domains or subdomains: en.acme.com, fr.acme.com, or acme.com for English and acme.fr for French. This looks more polished and is preferred by large enterprises with established international domain portfolios. The tradeoff is operational complexity: separate DNS configuration, potentially separate deployments, cross-domain cookie handling, and CORS considerations if your API is on a single domain.
Unless you have a specific business reason for domain routing, use sub-path. The implementation is simpler and the two approaches are equivalent from a user experience and SEO perspective when configured correctly.
The App Router Removed Built-In i18n
This surprises people: the i18n field in next.config.js that handled locale routing in the Pages Router does not work in the App Router. Removed. Not supported. The App Router's philosophy is that routing logic belongs in Middleware, not in the framework configuration. You implement i18n routing yourself, which sounds daunting but is actually cleaner and more flexible.
The standard implementation has three parts: a [locale] dynamic segment wrapping your app's routes, Middleware that detects the locale and rewrites the request, and a translation library that loads strings on the server. Each part is simple on its own; the complexity is in how they compose.
The [locale] Dynamic Segment
Move all your app routes under app/[locale]/. The directory structure looks like this:
The root app/layout.tsx (outside [locale]) should only contain the minimal shell — no content, no navigation, just the <html> and <body> tags if you need them at the very top level. Usually you move everything into app/[locale]/layout.tsx:
The notFound() call for unrecognized locales is important — without it, a request to /xyz/about would render normally with xyz as the locale, which is not what you want.
Locale Detection in Middleware
Middleware is where locale detection lives. The logic: read the Accept-Language header, check for a locale cookie (so returning users see their preferred language), and rewrite the request to the appropriate [locale] path:
The matcher is critical. Without excluding _next/static, _next/image, and other asset paths, Middleware would run on every asset request and add latency to image and font loading. Include your API routes in the exclusion list too — they don't need locale prefixes.
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion