Back/Module P-11 Internationalisation (i18n) Routing
Module P-11·23 min read

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-11 — 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:

app/
  [locale]/
    layout.tsx     ← sets lang attribute on <html>
    page.tsx       ← the homepage for each locale
    about/
      page.tsx
    blog/
      [slug]/
        page.tsx

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:

tsx
// app/[locale]/layout.tsx import { notFound } from 'next/navigation' const locales = ['en', 'fr', 'de', 'ja'] as const type Locale = typeof locales[number] export default async function LocaleLayout({ children, params, }: { children: React.ReactNode params: Promise<{ locale: string }> }) { const { locale } = await params if (!locales.includes(locale as Locale)) { notFound() } return ( <html lang={locale} dir={locale === 'ar' || locale === 'he' ? 'rtl' : 'ltr'}> <body>{children}</body> </html> ) }

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:

ts
// middleware.ts import { NextRequest, NextResponse } from 'next/server' import Negotiator from 'negotiator' import { match } from '@formatjs/intl-localematcher' const locales = ['en', 'fr', 'de', 'ja'] const defaultLocale = 'en' function getLocale(request: NextRequest): string { // Check cookie first — respect user's explicit choice const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value if (cookieLocale && locales.includes(cookieLocale)) { return cookieLocale } // Fall back to Accept-Language negotiation const negotiator = new Negotiator({ headers: { 'accept-language': request.headers.get('accept-language') ?? 'en' }, }) const languages = negotiator.languages() try { return match(languages, locales, defaultLocale) } catch { return defaultLocale } } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // Check if locale is already in the path const pathnameHasLocale = locales.some( (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` ) if (pathnameHasLocale) return NextResponse.next() // Rewrite to locale path const locale = getLocale(request) request.nextUrl.pathname = `/${locale}${pathname}` return NextResponse.rewrite(request.nextUrl) } export const config = { matcher: [ // Match all paths except static files and Next.js internals '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|api/).*)', ], }

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.


next-intl — the Right Library for This

You could implement translations yourself with JSON files and a t() function. Don't. Use next-intl. It's built specifically for the Next.js App Router, handles server-side and client-side translations with the same API, has excellent TypeScript support, and manages the locale-aware routing helpers that you'd otherwise write yourself.

Server Component usage:

tsx
// app/[locale]/page.tsx import { getTranslations } from 'next-intl/server' export default async function HomePage({ params, }: { params: Promise<{ locale: string }> }) { const { locale } = await params const t = await getTranslations({ locale, namespace: 'HomePage' }) return ( <main> <h1>{t('hero.title')}</h1> <p>{t('hero.description')}</p> </main> ) }

Client Component usage:

tsx
'use client' import { useTranslations } from 'next-intl' export function NavBar() { const t = useTranslations('Navigation') return ( <nav> <a href="/">{t('home')}</a> <a href="/about">{t('about')}</a> </nav> ) }

The message files live in your project, typically at messages/en.json, messages/fr.json, etc. The namespace system ('HomePage', 'Navigation') maps to nested keys in the JSON. This keeps large translation files manageable.

createNavigation() from next-intl gives you locale-aware versions of Link, redirect, and useRouter that automatically prefix paths with the current locale:

ts
// src/navigation.ts import { createNavigation } from 'next-intl/navigation' export const { Link, redirect, useRouter, usePathname } = createNavigation({ locales: ['en', 'fr', 'de', 'ja'] })

Use these instead of Next.js's built-in Link and useRouter in your UI code. The ergonomics are identical, but locale prefixing is automatic. You write <Link href="/about"> and it renders as <a href="/fr/about"> for French users.


Static Generation for All Locales

For pages using generateStaticParams, add the locale dimension:

tsx
// app/[locale]/blog/[slug]/page.tsx import { getAvailableLocales, getBlogSlugs } from '@/lib/content' export async function generateStaticParams() { const locales = getAvailableLocales() const slugs = await getBlogSlugs() return locales.flatMap((locale) => slugs.map((slug) => ({ locale, slug })) ) }

This generates all combinations of locale and slug at build time. A blog with 50 posts and 4 locales produces 200 static pages. They're all served from the CDN without hitting your server.


hreflang Tags for International SEO

Search engines use hreflang tags to understand which version of a page is meant for which audience, and to avoid treating localized pages as duplicate content. Missing hreflang is a common reason why international pages underperform in local search results.

Generate them via generateMetadata:

tsx
// app/[locale]/blog/[slug]/page.tsx import type { Metadata } from 'next' export async function generateMetadata({ params, }: { params: Promise<{ locale: string; slug: string }> }): Promise<Metadata> { const { locale, slug } = await params const locales = ['en', 'fr', 'de', 'ja'] return { alternates: { canonical: `https://acme.com/${locale}/blog/${slug}`, languages: Object.fromEntries( locales.map((l) => [l, `https://acme.com/${l}/blog/${slug}`]) ), }, } }

The languages object maps locale codes to their URLs. Next.js renders these as <link rel="alternate" hreflang="fr" href="..." /> tags in the document head. Include an x-default entry pointing to your default locale URL — it's what Google shows when no locale match is found:

ts
languages: { 'x-default': `https://acme.com/en/blog/${slug}`, en: `https://acme.com/en/blog/${slug}`, fr: `https://acme.com/fr/blog/${slug}`, }

Server-Side Date and Number Formatting

The Intl API handles date, number, currency, and list formatting in a locale-aware way. Always use it server-side rather than in Client Components, for one specific reason: hydration mismatches.

If you format a date in a Client Component and the user's browser locale differs from the server's locale, the server-rendered HTML and the client-rendered HTML will differ, causing a React hydration mismatch warning. Formatting server-side with an explicit locale eliminates the variable:

tsx
// app/[locale]/blog/[slug]/page.tsx — safe, explicit locale const formattedDate = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric', }).format(new Date(post.publishedAt))

If you must format in a Client Component, use suppressHydrationWarning on the element or use next-intl's useFormatter() hook, which is hydration-safe because it uses the locale from the context rather than the browser's navigator.language.


RTL Support

Right-to-left languages (Arabic, Hebrew, Persian, Urdu) require the dir="rtl" attribute on the <html> element. If you've structured your layout correctly with the locale parameter, this falls out naturally:

tsx
const rtlLocales = ['ar', 'he', 'fa', 'ur'] <html lang={locale} dir={rtlLocales.includes(locale) ? 'rtl' : 'ltr'} >

The CSS side is where RTL gets interesting. Modern CSS logical properties (margin-inline-start, padding-inline-end, border-inline-start) automatically flip for RTL without any JavaScript. Use them instead of physical properties (margin-left, padding-right) for any element that should mirror in RTL.

For Tailwind users: use ms- (margin-start), me- (margin-end), ps- (padding-start), pe- (padding-end) instead of ml-, mr-, pl-, pr-. Combined with dir="rtl" on the root element, the layout mirrors automatically.

If your design system was built without RTL in mind, expect to find hardcoded directional properties throughout. Auditing for LTR-specific styles is usually a significant task that can't be rushed. Budget time for it if you're adding Arabic or Hebrew to an existing product.

The good news: with dir="rtl" correctly set and logical CSS properties in place, many UI elements (navigation, forms, cards) just work. The elements that need explicit attention are custom icons and illustrations with inherent directionality, progress indicators, and any UI element where "first" and "last" mean something visually — like a breadcrumb or a timeline.


i18n × generateStaticParams — The Build Time Explosion

Everything in this module works correctly for small applications. When you have 5 locales and 10,000 products, it stops working correctly and starts taking down your CI pipeline.

The math: generateStaticParams generates one static page per param combination. With locale routing:

typescript
// app/[locale]/products/[slug]/page.tsx export async function generateStaticParams() { const products = await getAllProducts() // 10,000 products const locales = ['en', 'fr', 'de', 'ja', 'pt'] // 5 locales // This generates 50,000 static pages return locales.flatMap(locale => products.map(product => ({ locale, slug: product.slug })) ) }

50,000 pages at 100ms per page = 5,000 seconds = 83 minutes. On Vercel's hobby plan, builds timeout at 45 minutes. On Pro, at 60 minutes. Your build never finishes.

At 1,000 products, it's 5,000 pages = ~8 minutes. Manageable. At 5,000 products, it's 25,000 pages = ~40 minutes. Borderline. At 10,000+, it's a problem.

Mitigation 1: Pre-render Only Your Top Locales

Pre-render the locales that matter for SEO and user experience. Serve the rest on-demand with ISR:

typescript
export async function generateStaticParams() { const products = await getAllProducts() // Only pre-render English — highest traffic, most important for SEO // Other locales are generated on first request via ISR return products.map(product => ({ locale: 'en', slug: product.slug, })) } // Allow on-demand generation for non-pre-rendered locale×slug combinations export const dynamicParams = true // default, shown for clarity

With dynamicParams: true, a request for /fr/products/widget-42 that wasn't pre-rendered generates the page on first request and caches it. Subsequent requests hit the cache.

Mitigation 2: Pre-render Top N Products per Locale

typescript
export async function generateStaticParams() { // Pre-render only the 500 highest-traffic products in all locales const topProducts = await getTopProducts({ limit: 500 }) const locales = ['en', 'fr', 'de', 'ja', 'pt'] return locales.flatMap(locale => topProducts.map(product => ({ locale, slug: product.slug })) ) // 500 × 5 = 2,500 pages — manageable build time // The remaining 9,500 products are generated on-demand }

Use your analytics data to determine which products are worth pre-rendering. 80% of traffic goes to 20% of products — pre-render that 20%.

Mitigation 3: Partial Prerendering (PPR) as the Architecture Fix

With PPR, you pre-render the static shell (layout, navigation, product structure) at build time and stream the locale-specific content dynamically. You get the SEO benefit of a pre-rendered URL without generating every locale×slug combination at build time:

typescript
// next.config.ts export default { experimental: { ppr: true } } // app/[locale]/products/[slug]/page.tsx import { Suspense } from 'react' export default function ProductPage({ params }: { params: { locale: string; slug: string } }) { return ( <div> {/* Static shell — rendered at build time */} <ProductLayout> {/* Dynamic content — rendered per request, streamed */} <Suspense fallback={<ProductSkeleton />}> <LocalizedProductContent locale={params.locale} slug={params.slug} /> </Suspense> </ProductLayout> </div> ) }

With PPR, generateStaticParams can generate one entry per slug (not locale×slug), and the locale-specific rendering happens in the dynamic hole.

The hreflang Build-Time Trap

When generating sitemaps and hreflang tags for 50,000 locale×slug combinations, the generateSitemaps() function must handle pagination or the XML file exceeds Google's 50MB limit:

typescript
// app/sitemap.ts — paginated for large catalogs export async function generateSitemaps() { const count = await getProductCount() // 10,000 const localeCount = 5 const totalPages = Math.ceil((count * localeCount) / 1000) // 50 sitemap files return Array.from({ length: totalPages }, (_, i) => ({ id: i })) } export default async function sitemap({ id }: { id: number }) { const products = await getProducts({ skip: id * 200, take: 200 }) const locales = ['en', 'fr', 'de', 'ja', 'pt'] return locales.flatMap(locale => products.map(product => ({ url: `https://example.com/${locale}/products/${product.slug}`, lastModified: product.updatedAt, alternates: { languages: Object.fromEntries( locales.map(l => [l, `https://example.com/${l}/products/${product.slug}`]) ) } })) ) }

Each paginated sitemap is at /sitemap/0.xml, /sitemap/1.xml, etc. The main /sitemap.xml becomes a sitemap index file pointing to all of them.

Discussion