Suggest an editImprove this articleRefine the answer for “Internationalization (i18n) in Next.js”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Internationalization (i18n) in Next.js** routes users to locale-prefixed URLs like `/en/about` or `/de/about` using a `[locale]` dynamic segment and middleware that reads `Accept-Language` headers or cookies. ```tsx // middleware.ts detects language and redirects const locale = request.headers.get('accept-language')?.includes('de') ? 'de' : 'en'; return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url)); // Result: /about → /en/about or /de/about based on browser language ``` **Key point:** App Router i18n requires `[locale]` folders and middleware. The `i18n` key in `next.config.js` applies only to Pages Router and has no effect in App Router.Shown above the full answer for quick recall.Answer (EN)Image**Internationalization (i18n) in Next.js** is a routing pattern where locale codes live in the URL path, and middleware auto-detects the user's language to redirect them to the right version of the app. ## Theory ### TL;DR - Locale lives in the URL: `/en/about`, `/de/about` - same page, different language - App Router uses a `[locale]` dynamic segment; Pages Router uses `next.config.js` (they don't mix) - Middleware reads the `Accept-Language` header or a cookie, then redirects before the page renders - `next-intl` is the standard library for App Router - it handles pluralization, RTL, and RSC support - Skip i18n routing if your app only targets one language - the extra URL segments add noise without benefit ### Quick example Minimum setup: a `[locale]` folder and middleware that redirects `/` to `/en` or `/de`. ```tsx // app/[locale]/page.tsx export default function Home({ params }: { params: { locale: string } }) { return <h1>Hello from {params.locale.toUpperCase()}</h1>; // /en → "Hello from EN", /de → "Hello from DE" } // middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const locales = ['en', 'de']; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const hasLocale = locales.some( (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}` ); if (hasLocale) return NextResponse.next(); const locale = request.headers.get('accept-language')?.includes('de') ? 'de' : 'en'; return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url)); } export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'], }; ``` The `matcher` pattern in `config` keeps middleware away from `/_next/static` files and API routes. Without it, static assets return 404. ### How the routing works Every page lives under `app/[locale]/`. When a request comes in, middleware intercepts it before Next.js resolves the route. It checks cookies first, then `Accept-Language`, then falls back to the default locale. The URL gets rewritten or redirected to include the locale prefix, and `[locale]` becomes a param available in every page and layout under that folder. App Router and Pages Router handle i18n differently. Pages Router uses an `i18n` block in `next.config.js`. App Router ignores that config entirely - it needs the `[locale]` folder and middleware. Mixing both approaches breaks locale detection without throwing any errors. ### Setting up next-intl For anything beyond a toy project, use `next-intl`. It adds pluralization, date and number formatting, and RSC support that plain JSON objects don't provide. ```tsx // messages/en.json { "Dashboard": { "title": "User Dashboard", "stats": "{users} active users" } } // app/[locale]/layout.tsx import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; export default async function LocaleLayout({ children, params, }: { children: React.ReactNode; params: Promise<{ locale: string }>; }) { const { locale } = await params; const messages = await getMessages(); return ( <html lang={locale}> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> ); } ``` ```tsx // app/[locale]/dashboard/page.tsx import { useTranslations } from 'next-intl'; import { notFound } from 'next/navigation'; export default function Dashboard({ params: { locale } }: { params: { locale: string } }) { if (!['en', 'de'].includes(locale)) notFound(); const t = useTranslations('Dashboard'); return ( <div> <h1>{t('title')}</h1> <p>{t('stats', { users: 42 })}</p> {/* "42 active users" */} </div> ); } ``` That `notFound()` call for unknown locales is one of the easiest things to skip at first. Without it, a request to `/xx/dashboard` renders the page with an empty locale string instead of returning a 404. ### Language switcher ```tsx 'use client'; import { usePathname, useRouter } from 'next/navigation'; export function LanguageSwitcher() { const pathname = usePathname(); const router = useRouter(); const switchLocale = (newLocale: string) => { const segments = pathname.split('/'); segments[1] = newLocale; // replace locale segment router.push(segments.join('/')); }; return ( <div> <button onClick={() => switchLocale('en')}>EN</button> <button onClick={() => switchLocale('de')}>DE</button> </div> ); } ``` ### When to use i18n routing - Multi-language SaaS or e-commerce: subpath routing (`/en/shop`) beats separate domains for SEO because cookies and sessions are shared across locales - Single-language app: plain Next.js routing, no `[locale]` folder needed - Static export (`next export`): avoid i18n routing. Middleware rewrites depend on Node.js runtime; static export removes the server. Use `generateStaticParams()` per locale instead - Legacy Pages Router project: keep the `i18n.locales` array in `next.config.js`, don't migrate just for i18n ### Common mistakes **Mistake 1: No matcher in middleware config.** ```tsx // Wrong - rewrites /_next/static → /en/_next/static (assets 404) export function middleware(request: NextRequest) { return NextResponse.rewrite(new URL('/en' + request.nextUrl.pathname, request.url)); } ``` Fix: always add `config.matcher` to exclude `_next`, `api`, and static files. **Mistake 2: Hardcoding translations in components.** ```tsx // Wrong <h1>{locale === 'de' ? 'Hallo' : 'Hello'}</h1> ``` This breaks on pluralization, RTL languages, and anything beyond 10-15 strings. Use next-intl with ICU format: `{count, plural, one {# user} other {# users}}`. **Mistake 3: Using `next.config.js` i18n with App Router.** App Router ignores the `i18n` config key. The page still renders, but locale detection and redirects don't happen. Delete it from the config and use `[locale]` folders with middleware. **Mistake 4: Forgetting `notFound()` for invalid locales.** Without locale validation in each page, any malformed URL renders a broken page. Validate early and call `notFound()` for anything outside your supported locales array. **Mistake 5: Losing locale on `<Link>` navigation.** ```tsx // Wrong - /products loses the locale prefix <Link href="/products">Products</Link> // Right - locale-aware Link from next-intl routing wrapper import { Link } from '@/i18n/routing'; <Link href="/products">Products</Link> ``` ### Real-world usage - Vercel.com uses `next-intl` for docs with 30+ locales routed through middleware - Shopify Hydrogen uses subpath i18n with domain mapping as a fallback - For RTL languages (Arabic, Hebrew): add `dir="rtl"` to `<html>` based on locale, use CSS logical properties (`margin-inline-start` instead of `margin-left`), and `next-intl`'s `direction()` helper handles detection automatically ### Follow-up questions **Q:** How does Next.js detect the locale without middleware? **A:** It doesn't. The `[locale]` param is just a URL segment. Without middleware, nothing sets it automatically. Middleware is the only place where `Accept-Language` and cookies get read for redirect logic. **Q:** Subpath vs domain strategy - which one for SEO? **A:** Subpath (`/en/about`) shares cookies, sessions, and link equity across locales. Domain strategy (`en.example.com`) requires separate DNS config and per-hostname middleware matchers. Subpath is simpler for most cases. Domains make sense only when you need separate analytics or distinct brand identities per region. **Q:** What breaks when you use `next export` with i18n? **A:** Middleware rewrites rely on Node.js runtime. Static export removes the server, so locale detection doesn't run. Fix with `generateStaticParams()` to pre-render each locale at build time, or deploy to a platform that keeps the runtime (Vercel, any Node.js host). **Q:** How do you handle RTL languages like Arabic? **A:** Set `dir="rtl"` on `<html>` based on the locale. Use CSS logical properties (`padding-inline`, `margin-block`) instead of directional ones (`padding-left`, `margin-right`). `next-intl` has a `direction()` helper that returns `"ltr"` or `"rtl"` from the locale string. **Q:** (Senior) In a hybrid app with both Pages Router and App Router, how do you share locale state? **A:** The cleanest approach is a root middleware that sets a `NEXT_LOCALE` cookie. Pages Router reads locale via `next.config.js` detection, App Router reads it from `params`. They can overlap if middleware handles both routing prefixes. In practice, avoid hybrids - migrate fully or keep i18n only in the router that owns the pages you're localizing. ## Examples ### Basic setup: folder structure and locale param Minimum to get locale-aware pages working in App Router, no library required. ```tsx // app/[locale]/layout.tsx export default function LocaleLayout({ children, params, }: { children: React.ReactNode; params: { locale: string }; }) { return ( <html lang={params.locale}> <body>{children}</body> </html> ); } // app/[locale]/page.tsx export default function Home({ params }: { params: { locale: string } }) { const greetings: Record<string, string> = { en: 'Welcome', de: 'Willkommen', es: 'Bienvenido', }; return <h1>{greetings[params.locale] ?? greetings.en}</h1>; // /en → "Welcome", /de → "Willkommen" } ``` No library here. The locale is just a URL param and translations are plain objects. This works for 3-5 strings. Beyond that, reach for next-intl. ### Production dashboard with next-intl and locale validation Real scenario: a dashboard that validates the locale, loads JSON messages, and renders translated content with interpolation and pluralization. ```tsx // messages/en.json { "Dashboard": { "title": "User Dashboard", "activeUsers": "{count, plural, one {# active user} other {# active users}}", "lastSeen": "Last seen {time}" } } // app/[locale]/dashboard/page.tsx import { useTranslations } from 'next-intl'; import { notFound } from 'next/navigation'; const supportedLocales = ['en', 'de', 'es']; export default function Dashboard({ params: { locale } }: { params: { locale: string } }) { if (!supportedLocales.includes(locale)) notFound(); const t = useTranslations('Dashboard'); return ( <main> <h1>{t('title')}</h1> <p>{t('activeUsers', { count: 1 })}</p> {/* "1 active user" */} <p>{t('activeUsers', { count: 42 })}</p> {/* "42 active users" */} <p>{t('lastSeen', { time: '2 hours ago' })}</p> </main> ); } ``` ICU format handles pluralization automatically. The `{count, plural, one {...} other {...}}` pattern means no if/else in components. ### Advanced: hiding the default locale prefix Some teams want `/about` instead of `/en/about` for the default language. Middleware handles the redirect without exposing the default prefix in URLs. ```tsx // middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const locales = ['en', 'de'] as const; function getLocale(request: NextRequest): 'en' | 'de' { const cookie = request.cookies.get('locale')?.value; if (cookie === 'de') return 'de'; return request.headers.get('accept-language')?.includes('de') ? 'de' : 'en'; } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const pathnameHasLocale = locales.some( (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}` ); if (!pathnameHasLocale) { const locale = getLocale(request); // /about → /en/about (or /de/about for German browsers) return NextResponse.redirect( new URL(`/${locale}${pathname === '/' ? '' : pathname}`, request.url) ); } return NextResponse.next(); } export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'], }; ``` One edge case: if a page slug matches a locale code (like a `/de` blog post about Germany), middleware intercepts it as a locale prefix. Use nested dynamic segments `[locale]/blog/[slug]` to avoid the collision.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.