Next.js i18n Routing Setup
Next.js App Router i18n routing means putting a [locale] dynamic segment at the root of app/, detecting the locale in middleware.ts, and pre-rendering every locale with generateStaticParams — without it you get Error: Cannot read properties of undefined (reading 'locale') or a 404 on /de. This guide wires up the segment, the redirect middleware, and the message loader for both next-intl and next-i18next.
The App Router deliberately dropped the built-in i18n config key that the Pages Router exposed in next.config.js. That key still works for the Pages Router, but under app/ it is silently ignored, which is the single most common reason a freshly-migrated project serves every request as the default locale. Everything below assumes the App Router (app/ directory, Next.js 14 or 15); a short Pages Router note appears in the framework variants section.
[locale] segment.Prerequisites
Concept & spec
A locale tag in the URL is a BCP 47 / RFC 5646 language tag (en, de, pt-BR). The App Router has no native locale awareness, so you reconstruct it from three primitives: a dynamic route segment that captures the tag, a middleware that injects the segment when it is missing, and a static-params function that tells Next.js which tags to pre-render. Negotiation itself — turning an Accept-Language header into a chosen tag — follows the matching rules in RFC 4647; the algorithm and its edge cases are covered in depth by the locale negotiation strategies guide, which this routing layer consumes.
This page sits inside the broader Frontend Framework i18n & Component Routing area: routing is the entry point that every other concern (message injection, formatting, fallback) depends on. Get the segment and middleware deterministic first, then layer messages on top.
Three properties make a routing layer correct. It must be total — every reachable URL resolves to exactly one locale, with no path that escapes detection. It must be idempotent — a request that already carries a valid prefix is passed through untouched, never redirected again, or you get loops. And it must be statically analyzable — Next.js needs to know at build time which locale trees exist, which is precisely what generateStaticParams provides. The three primitives map one-to-one onto these properties: the segment gives totality of capture, the middleware gives idempotent prefix injection, and generateStaticParams gives static analyzability. Skip any one and a class of bugs follows directly.
Step-by-step implementation
Step 1 — Create the [locale] segment
Rename your root app/ content so every route lives under a dynamic segment. The folder name (locale, lang, whatever) is the param key you will read everywhere, so pick one and stay consistent.
mkdir -p app/[locale]
git mv app/page.tsx app/[locale]/page.tsx
git mv app/layout.tsx app/[locale]/layout.tsx
# repeat for every route group under app/
After this, /de/products maps to app/[locale]/products/page.tsx with params.locale === 'de'. Nothing under app/ should remain outside [locale] except app/not-found.tsx and truly locale-agnostic API handlers.
Step 2 — Declare your locales once
Centralize the locale list so middleware, layout, and the catalog loader share a single source of truth. A typed as const array gives you a union type for free.
// i18n/config.ts
export const locales = ['en', 'de', 'ja'] as const;
export const defaultLocale = 'en' satisfies (typeof locales)[number];
export type Locale = (typeof locales)[number];
export function isLocale(value: string): value is Locale {
return (locales as readonly string[]).includes(value);
}
Import locales and defaultLocale from this file in every later step. Never re-hardcode the array — a drifted copy is how /ja starts 404-ing after someone adds a language.
Step 3 — Add locale-detecting middleware
The middleware runs at the edge before any route resolves. It reads cookie, then Accept-Language, then falls back to the default, and issues a single 307 redirect to the prefixed path. Matching Accept-Language against your allowlist is best delegated to @formatjs/intl-localematcher rather than hand-rolled string slicing.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
import { locales, defaultLocale } from './i18n/config';
const COOKIE = 'NEXT_LOCALE';
function resolveLocale(req: NextRequest): string {
const cookie = req.cookies.get(COOKIE)?.value;
if (cookie && (locales as readonly string[]).includes(cookie)) return cookie;
const header = req.headers.get('accept-language') ?? undefined;
const requested = new Negotiator({ headers: { 'accept-language': header } }).languages();
return match(requested, locales as readonly string[], defaultLocale);
}
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const hasLocale = locales.some(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
);
if (hasLocale) return NextResponse.next();
const locale = resolveLocale(req);
const url = req.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
const res = NextResponse.redirect(url, 307); // 307 preserves method
res.cookies.set(COOKIE, locale, { maxAge: 31_536_000, path: '/' });
return res;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};
The matcher excludes api, Next internals, and anything with a file extension so static assets never pay the middleware cost. Deeper middleware tuning lives in the App Router i18n middleware configuration guide.
Step 4 — Pre-render every locale with generateStaticParams
Export generateStaticParams from the layout so Next.js builds one static tree per locale instead of rendering on demand. Validate the incoming param and call notFound() for unknown tags — otherwise an attacker (or a bad link) requesting /xx/ reaches your loader with garbage.
// app/[locale]/layout.tsx
import { notFound } from 'next/navigation';
import { locales, isLocale } from '@/i18n/config';
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>; // Next 15: params is async
}) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
In Next.js 15, params is a Promise and must be awaited; in 14 it is a plain object. Setting <html lang> from the validated param is what keeps screen readers and :lang() CSS correct.
Step 5 — Load and serve messages
With next-intl, route requests through its plugin and provide messages from a server component. The provider streams messages to client components without a second fetch.
// app/[locale]/layout.tsx (additions)
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
// inside LocaleLayout, after the isLocale guard:
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
Pair this with i18n/request.ts (the next-intl request config) that imports ./messages/${locale}.json. Now useTranslations() works in client components and getTranslations() in server components.
Configuration reference
| Option | Type | Description / default |
|---|---|---|
locales |
readonly string[] |
All BCP 47 tags you serve. No default — you must declare it. |
defaultLocale |
string |
Tag used when negotiation finds no match. Often 'en'. |
localePrefix (next-intl) |
'always' | 'as-needed' | 'never' |
Whether the default locale is prefixed. Default 'always'. |
localeDetection |
boolean |
Pages Router only. Ignored under App Router — detection is your middleware. |
matcher (middleware config) |
string[] |
Path patterns the middleware runs on. Exclude api, _next, asset extensions. |
dynamicParams |
boolean |
If false, only generateStaticParams tags render; others 404. Default true. |
maxAge (locale cookie) |
number |
Cookie lifetime in seconds. 31_536_000 = one year. |
Framework variants
next-intl (recommended for App Router)
next-intl@3+ is App-Router-native: it wraps next.config.js with createNextIntlPlugin('./i18n/request.ts'), owns the message provider, and gives you typed useTranslations. Use localePrefix: 'as-needed' if you want the default locale to live at / instead of /en.
// next.config.js
const createNextIntlPlugin = require('next-intl/plugin');
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
module.exports = withNextIntl({});
next-i18next
next-i18next predates the App Router and is built around the Pages Router and getServerSideProps. For App Router projects it requires the i18next + react-i18next primitives wired manually into a server initializer; many teams migrating off it land on next-intl. The component-level patterns it shares with react-i18next are detailed in the React i18next component patterns guide.
Pages Router (legacy)
If you are still on pages/, keep the native i18n key — it gives you locale, locales, and defaultLocale on the router and auto-prefixes routes without custom middleware:
// next.config.js (Pages Router only)
module.exports = {
i18n: { locales: ['en', 'de', 'ja'], defaultLocale: 'en', localeDetection: false },
};
This key does nothing under app/. Mixing the two routers in one project means the App Router routes need the middleware approach above regardless.
Cross-cutting concerns
Caching at the edge. Because the response varies by negotiated locale, set Vary: Accept-Language, Cookie on redirected responses so a shared CDN does not serve one user’s German redirect to another user’s English request. Statically generated [locale] pages are safe to cache aggressively once the prefix is present; it is only the locale-less redirect that must vary.
Accessibility. The <html lang> attribute set from the validated param is not cosmetic — screen readers switch voice and pronunciation rules from it, and CSS :lang() selectors and hyphenation depend on it. For right-to-left languages add dir="rtl" on the same element, derived from the locale, so layout mirroring engages without per-component overrides.
SEO and alternates. Emit hreflang alternates so each localized URL points at its siblings, and keep one canonical address per page (the reason the middleware redirects rather than rewrites). A locale-less URL that resolves a different locale per visitor is the classic cause of duplicate-content confusion; the single 307 to a stable prefix prevents it.
Compliance. The locale cookie is a functional preference, not tracking, but in strict consent regimes treat it as such: it is safe to set pre-consent because it carries no identity, only a language tag. Document that distinction so it survives a privacy review.
Verification
Confirm the redirect, the prefixed serving, and the lang attribute with one Playwright spec, then gate it in CI.
// tests/i18n-routing.spec.ts
import { test, expect } from '@playwright/test';
test('locale-less request redirects to a prefixed path', async ({ page }) => {
const res = await page.goto('/products');
expect(res?.url()).toMatch(/\/(en|de|ja)\/products$/);
});
for (const locale of ['en', 'de', 'ja']) {
test(`${locale} route sets html[lang]`, async ({ page }) => {
await page.goto(`/${locale}/products`);
await expect(page.locator('html')).toHaveAttribute('lang', locale);
});
}
Expected output: all four assertions pass. The CI step is a single command:
# .github/workflows/ci.yml (excerpt)
- name: i18n routing tests
run: npx playwright test tests/i18n-routing.spec.ts
Common pitfalls
i18nkey innext.config.jsdoes nothing under App Router. Detection silently falls back to default. Remove the key and rely on middleware.- Middleware matcher catches static assets. A too-broad matcher rewrites
.png/.cssrequests and breaks them. Keep the.*\\..*exclusion. generateStaticParamsomits a locale,dynamicParamsisfalse. That locale 404s in production though it works in dev. Generate every tag from the sharedlocalesarray.paramsnot awaited in Next 15. Readingparams.localesynchronously throws or yieldsundefined; awaitparamsfirst.- Locale switcher swaps text but not the URL. State updates without a
router.push('/de/...')desync server and client and trigger a hydration mismatch after locale switch — always navigate, do not mutate state in place. - Redirect loop on
/. If/is in the matcher but no locale prefix is ever added for it, middleware redirects forever. Ensure the resolved path always gains a prefix.
FAQ
Why does the App Router ignore the i18n key in next.config.js?
The i18n config block is a Pages Router feature tied to that router’s data-fetching and routing model. The App Router replaced built-in i18n routing with explicit primitives — dynamic segments, middleware, and generateStaticParams — so the key is read but has no effect. If your app/ project serves only the default locale, an orphaned i18n key giving a false sense of configuration is usually why.
Do I need middleware if I always link with the locale prefix?
Yes, for the entry points you do not control: a user typing example.com/products, a shared link without a prefix, or a search engine result for the bare domain. Middleware is what turns those locale-less requests into a prefixed, negotiated path. If every inbound link were already prefixed you could drop it, but that is rarely true in practice.
Should the default locale be prefixed (/en) or live at the root (/)?
Both are valid. localePrefix: 'always' (/en/products) is the most predictable — every locale is symmetric and there is no special-cased root. 'as-needed' puts the default at /products for cleaner URLs but means middleware must distinguish “no locale, use default” from “default locale at root”. Pick 'always' unless a marketing requirement forces clean default-locale URLs.
How do I avoid loading every locale’s messages into the client bundle?
Load only the active locale’s catalog in the request config (import(\./messages/${locale}.json`)) and let NextIntlClientProvider` ship just those messages. Never import all catalogs eagerly. For large catalogs, split by namespace and request only the namespaces a route renders.
Why is a 307 redirect used instead of a 308 or a rewrite?
A 307 is a temporary redirect that preserves the request method (a POST stays a POST), which matters when an unprefixed form submission lands on the middleware. A rewrite would serve the localized route under the unprefixed URL, leaving two URLs for one page and confusing canonical resolution. The 307 makes the prefixed URL the single canonical address.
Related
- Next.js App Router i18n middleware configuration — matcher tuning, cookie handling, and edge-runtime constraints for the middleware above.
- Hydration mismatch after locale switch — why swapping locale in state instead of navigating desyncs server and client.
- Locale negotiation strategies — the
Accept-Languageparsing and RFC 4647 matching your middleware delegates to. - React i18next component patterns — component-level message injection shared with the
next-i18nextpath. - SvelteKit internationalization basics — the same route-prefixing problem solved in a different framework.