Internationalization (i18n) in Next.js
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 usesnext.config.js(they don't mix) - Middleware reads the
Accept-Languageheader or a cookie, then redirects before the page renders next-intlis 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.
// 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.
// 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>
);
}// 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
'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. UsegenerateStaticParams()per locale instead - Legacy Pages Router project: keep the
i18n.localesarray innext.config.js, don't migrate just for i18n
Common mistakes
Mistake 1: No matcher in middleware config.
// 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.
// 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.
// 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-intlfor 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-startinstead ofmargin-left), andnext-intl'sdirection()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.
// 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.
// 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.
// 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.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.