
Next.js Internationalization
Most frameworks that promise "internationalization support" actually mean one of two very different things: either they ship a router that understands locales natively, or they leave the entire problem to you and just try to stay out of your way. Next.js firmly belongs to the second camp. There's no i18n config block, no built-in locale switcher, and no magic that rewrites your URLs for you. What you get instead is a small set of primitives — dynamic route segments, Proxy, Server Components, and a couple of purpose-built APIs — that are flexible enough to build any i18n architecture you want, at the cost of having to actually build it.
That tradeoff surprises people coming from frameworks with opinionated i18n routing baked in. The good news is that once you understand the handful of pieces involved, wiring up a multi-language Next.js app is a few hours of work, not a research project. This article walks through locale detection, route structure, localized content, and the newer next/root-params API that removes a genuinely annoying amount of prop-drilling from the whole exercise.
Locale, Localization, and Routing Are Three Separate Problems
Before writing any code, it's worth pinning down the vocabulary, because the three concepts get conflated constantly and that's where confusion creeps in.
A locale is just an identifier for a language plus, optionally, a region and formatting convention. en-US is English as spoken (and formatted — dates, currency, number separators) in the United States. nl-NL is Dutch as spoken in the Netherlands. nl on its own is just "Dutch," with no regional formatting attached. None of this is Next.js-specific; it's the same BCP 47 convention every other web platform uses.
Localization is the act of showing different content based on the selected locale — swapping "Add to Cart" for "Toevoegen aan Winkelwagen." This has nothing to do with routing. You could localize an app that has no URL structure for locales at all, just a client-side language toggle stored in a cookie.
Internationalized routing is the decision to bake the locale into the URL itself, either as a sub-path (/nl/products) or a full domain (my-site.nl/products). This is the part Next.js actually gives you tools for, because it's a routing concern and Next.js is fundamentally a router.
Keeping these three separate in your head makes the rest of this article much easier to follow, because each one is solved by a completely different mechanism.
Detecting the User's Preferred Locale
The most common entry point for a first-time visitor is: they type your bare domain into the address bar, with no locale in the URL at all. Something has to decide which locale to show them, and the standard signal for that is the Accept-Language request header — the string every browser sends listing the user's preferred languages, in order, with quality weights.
Accept-Language: en-US,en;q=0.9,nl;q=0.8
Parsing that header correctly — respecting quality values, falling back gracefully, matching against only the locales you actually support — is fiddly enough that you shouldn't hand-roll it. Two small libraries handle it well together: negotiator parses the header into an ordered list of languages, and @formatjs/intl-localematcher matches that list against your supported locales using the same algorithm browsers use internally.
npm install negotiator @formatjs/intl-localematcher
import { match } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
const headers = { "accept-language": "en-US,en;q=0.5" };
const languages = new Negotiator({ headers }).languages();
const locales = ["en-US", "nl-NL", "nl"];
const defaultLocale = "en-US";
match(languages, locales, defaultLocale); // -> 'en-US'
That's the whole detection layer. It takes a raw header string and your list of supported locales, and gives you back the single best match, falling back to your default if nothing matches. It's deliberately unopinionated about where you call it from — which brings us to the actual routing.
Wiring Detection into Proxy
This is where the pieces connect. Proxy (the file convention that replaced Middleware in recent Next.js versions — I covered the rename in my article on Proxy if you haven't run into it yet) is the only place in the App Router that runs before a route is matched, which makes it the natural home for locale redirection.
The logic is straightforward: look at the incoming pathname, check whether it already starts with a supported locale, and if it doesn't, figure out the visitor's preferred locale and redirect them to the locale-prefixed version of the same path.
// proxy.js
import { NextResponse } from "next/server";
const locales = ["en-US", "nl-NL", "nl"];
// Reuse the detection logic from the previous section here.
function getLocale(request) {
/* ... */
}
export function proxy(request) {
const { pathname } = request.nextUrl;
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
if (pathnameHasLocale) return;
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
// Incoming request: /products
// Redirected to: /en-US/products
return NextResponse.redirect(request.nextUrl);
}
export const config = {
matcher: [
// Skip all internal paths (_next)
"/((?!_next).*)",
],
};
The matcher config is the part I see people get wrong most often, and it's worth dwelling on because the failure mode is subtle. If your matcher is too broad — say, just /:path* with no exclusions — you'll end up redirecting requests for /favicon.ico, /robots.txt, /sitemap.xml, and your static assets under /_next/static, none of which should ever get a locale prefix. The pattern above excludes _next, but in a real project you'll usually want to widen that exclusion to cover any other top-level static routes you serve (API routes you want locale-agnostic, a /manifest.json, image files in public/, and so on). Test this by hitting your sitemap and robots endpoints directly after wiring up the redirect — it's an easy thing to ship broken and not notice until a crawler reports 404s or, worse, silently indexes your redirect chain instead of your content.
Nesting Routes Under app/[lang]
Once Proxy guarantees every request that reaches your routes has a locale prefix, the routing side of the problem becomes almost mechanical: every special file in app/ moves one level deeper, nested under a dynamic segment.
app/
[lang]/
layout.tsx
page.tsx
products/
page.tsx
Every layout and page nested under [lang] automatically receives a lang param, resolved from whatever segment of the URL matched.
// app/[lang]/page.tsx
export default async function Page({ params }: PageProps<"/[lang]">) {
const { lang } = await params;
// /en-US/products -> lang is "en-US"
return <p>Current locale: {lang}</p>;
}
Note the PageProps helper in the type signature — this is a globally available TypeScript helper Next.js generates for you based on your actual route structure, so you get accurate typing for params without hand-writing an interface per route. The equivalent LayoutProps helper exists for layouts. Both are worth using even outside i18n contexts; they're one of those small quality-of-life additions that used to require boilerplate and now don't.
The root layout itself can (and usually should) move into app/[lang]/layout.tsx too, since that's where you'll set the lang attribute on the <html> tag — a detail that's easy to forget and that screen readers and SEO crawlers both actually care about:
// app/[lang]/layout.tsx
export default async function RootLayout({
children,
params,
}: LayoutProps<"/[lang]">) {
return (
<html lang={(await params).lang}>
<body>{children}</body>
</html>
);
}
If you skip this, you end up with an English-language lang="en" attribute sitting on a page that's rendering entirely in Dutch, which trips up both accessibility tooling (screen readers use lang to pick a pronunciation engine) and search engines trying to serve the right locale to the right searchers.
Building the Localization Layer: Dictionaries
Routing gets you a lang value. Turning that into actual translated content is a separate, framework-agnostic problem — Next.js doesn't have an opinion here, which is both a relief and a little bit of a blank page.
The simplest workable pattern is a dictionary: a plain object mapping keys to translated strings, one file per locale.
// dictionaries/en.json
{
"products": {
"cart": "Add to Cart"
}
}
// dictionaries/nl.json
{
"products": {
"cart": "Toevoegen aan Winkelwagen"
}
}
A small loader function maps a locale string to a dynamic import of the right file:
// app/[lang]/dictionaries.ts
import "server-only";
const dictionaries = {
en: () => import("./dictionaries/en.json").then((mod) => mod.default),
nl: () => import("./dictionaries/nl.json").then((mod) => mod.default),
};
export type Locale = keyof typeof dictionaries;
export const hasLocale = (locale: string): locale is Locale =>
locale in dictionaries;
export const getDictionary = async (locale: Locale) => dictionaries[locale]();
The import "server-only" at the top isn't decorative — it's a build-time guard that throws if anything ever tries to import this module from a Client Component. That matters because your dictionary files can get large once a real app accumulates hundreds of translated strings, and you never want that JSON shipped to the browser as part of a client bundle by accident.
hasLocale does double duty. Because lang arrives from params typed as a plain string (Next.js can't statically know it'll always be one of your three supported locales), hasLocale acts as a type-narrowing guard and a runtime safety net:
// app/[lang]/page.tsx
import { notFound } from "next/navigation";
import { getDictionary, hasLocale } from "./dictionaries";
export default async function Page({ params }: PageProps<"/[lang]">) {
const { lang } = await params;
if (!hasLocale(lang)) notFound();
const dict = await getDictionary(lang);
return <button>{dict.products.cart}</button>; // Add to Cart
}
If someone hits /fr/products and you don't support French yet, this returns a proper 404 instead of throwing an unhandled runtime error when dictionaries[lang] comes back undefined. That's a meaningfully better failure mode, and it costs one if statement.
Why None of This Costs You Client-Side Bundle Size
Here's the detail that's easy to miss if you're used to client-rendered SPAs: everything above — the dictionary lookup, the JSON import, the hasLocale check — runs inside a Server Component, which means it runs only on the server. The dictionary file itself never crosses the network to the browser. Only the resulting rendered HTML (<button>Add to Cart</button>) does.
This is a genuinely nice property of doing i18n in the App Router versus a client-side-first i18n library in a traditional SPA: you can have a dictionary with thousands of keys across a dozen locales, and it costs your users precisely nothing in JavaScript payload, because the translation step already happened before any bytes left the server. The only place this changes is if you need locale strings inside a Client Component for something interactive — a language switcher's own labels, for instance — in which case you pass the specific strings down as props from a Server Component parent rather than importing the whole dictionary client-side.
Avoiding Prop-Drilling with next/root-params
The dictionary pattern above works, but it has an annoying implication: every Server Component and server-side utility that needs a translated string needs lang passed down to it, which usually means threading a lang parameter through several layers of function calls that have nothing to do with routing.
Next.js addresses this with next/root-params, which exports a getter function for each dynamic segment that sits above your root layout. Since every route in this setup lives under app/[lang], lang qualifies as a root parameter, and any server-side code — not just page components — can call its getter directly, with no prop passed at all.
// app/[lang]/dictionaries.ts
import { lang } from "next/root-params";
import { notFound } from "next/navigation";
const dictionaries = {
en: () => import("./dictionaries/en.json").then((mod) => mod.default),
nl: () => import("./dictionaries/nl.json").then((mod) => mod.default),
};
export type Locale = keyof typeof dictionaries;
export const hasLocale = (locale: string): locale is Locale =>
locale in dictionaries;
export const getDictionary = async () => {
const locale = await lang();
if (!hasLocale(locale)) notFound();
return dictionaries[locale]();
};
Callers now invoke getDictionary() with zero arguments:
// app/[lang]/page.tsx
import { getDictionary } from "./dictionaries";
export default async function Page() {
const dict = await getDictionary();
return <button>{dict.products.cart}</button>;
}
A subtlety worth internalizing: root parameter getters only work in Server Components and server-side utility functions — they're unavailable inside Client Components, Server Actions, and Route Handlers. If you need the locale inside one of those, you're back to reading it from params or threading it explicitly, because next/root-params is specifically solving the "deeply nested server-side code" version of this problem, not every possible place lang might be needed.
There's also a caching nuance the docs don't spell out explicitly, but that follows from how root params work: because lang comes from a route segment that generateStaticParams already enumerates at build time — rather than from a runtime signal like a cookie or a request header — reading it doesn't force a function into dynamic rendering the way calling cookies() or headers() would. It's statically knowable for any path your build already generates, so a function that reads lang via next/root-params can still participate in static rendering or a cached (use cache) code path, whereas one that reads the raw Accept-Language header cannot.
Static Generation, Per Locale
Because each locale is just a value of a dynamic segment, generating static pages for a fixed set of locales is exactly the same mechanism you'd use for any other dynamic route: generateStaticParams.
// app/[lang]/layout.tsx
export async function generateStaticParams() {
return [{ lang: "en-US" }, { lang: "de" }];
}
export default async function RootLayout({
children,
params,
}: LayoutProps<"/[lang]">) {
return (
<html lang={(await params).lang}>
<body>{children}</body>
</html>
);
}
Put this on the root layout and every route beneath it gets pre-rendered once per listed locale at build time — /en-US, /en-US/products, /de, /de/products, and so on — with no per-route duplication of the locale list. Add a locale to this array and every existing page under [lang] picks it up automatically on the next build.
Sub-Path Routing vs. Domain Routing
Everything in this article assumes sub-path routing — /nl/products — because it's simpler to set up and it's what the vast majority of Next.js i18n implementations use. But the alternative, domain-based routing (my-site.nl/products, my-site.com/products), is worth knowing about even if you don't reach for it immediately.
Domain routing means each locale lives on its own domain or subdomain, and your Proxy logic redirects based on request.nextUrl.hostname rather than the pathname. The tradeoffs run in the opposite direction from what people usually assume:
Sub-path routing is easier to deploy (one domain, one SSL certificate, one DNS entry), easier to test locally, and keeps session/auth cookies working uniformly across locales without any special configuration, since cookies are scoped per-domain by default.
Domain routing is heavier to set up — you need DNS entries and certificates for every domain, and cross-domain session sharing requires deliberate cookie configuration (or an entirely separate auth flow per domain) — but it can matter for SEO in specific markets, since some search engines and users treat a country-code top-level domain (.nl, .de) as a stronger locality signal than a sub-path. It also lets you have genuinely separate branding or even separate teams owning each locale's domain, which occasionally matters for large multinational organizations more than it does for most projects.
Unless you have a specific business reason to want separate domains — a legal requirement, a strong regional brand identity, or an existing DNS setup you're migrating into — start with sub-path routing. It's less infrastructure to maintain and the migration path from sub-path to domain-based, if you ever need it, is mostly a Proxy rewrite rather than an application rearchitecture.
Where Dictionaries Start to Fall Short
Plain JSON dictionaries are a fine starting point, but they get uncomfortable fast once your content has anything beyond static strings: pluralization ("1 item" vs. "3 items"), interpolated variables ("Welcome back, "), or locale-aware number and date formatting.
For formatting, you don't need a library at all — the Intl object is built into JavaScript and handles this correctly per-locale:
new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" }).format(
1234.5,
); // "€ 1.234,50"
new Intl.DateTimeFormat("en-US").format(new Date()); // "1/28/2027"
For pluralization and interpolation, hand-rolling your own dictionary format eventually turns into reinventing a real i18n library badly. If your project's translation needs grow past "swap this string for that string," it's worth adopting a dedicated library rather than extending the pattern above — next-intl is the most widely used option built specifically for the App Router, and it layers cleanly on top of everything described in this article (it still uses the same app/[lang] routing structure) while adding proper ICU message format support for plurals and interpolation.
Common Mistakes Worth Watching For
Redirect loops from an over-eager matcher. If your Proxy matcher doesn't exclude API routes, and one of those API routes is called from client-side JavaScript expecting JSON, an unexpected redirect response instead of the expected data is a confusing bug to track down. Explicitly test your API routes after adding locale redirection.
Forgetting generateMetadata needs the locale too. Page titles, descriptions, and Open Graph metadata should be localized the same way your visible content is — pull them from the same dictionary inside generateMetadata, rather than leaving your <title> tag hard-coded in one language while the page body is in another.
No hreflang alternates. If you want search engines to serve the right locale to the right searcher, your metadata needs alternates.languages pointing at the equivalent URL in every other locale you support. This is easy to add once you have the locale list centralized (which the generateStaticParams array already gives you) and easy to forget if you don't think about it until after launch.
Assuming the default locale needs no prefix. Some projects want / to serve the default locale directly, with only non-default locales getting a path prefix (/ for English, /nl for Dutch). This is a legitimate and common pattern, but it means your Proxy logic and your pathnameHasLocale check both need an extra branch to special-case the default — it doesn't fall out of the basic pattern shown above for free.
Key Takeaways
| Concern | Where it's solved |
|---|---|
| Detecting preferred language | negotiator + @formatjs/intl-localematcher, called from Proxy |
| Redirecting to a locale-prefixed URL | Proxy, with a carefully scoped matcher |
| Route structure | Every special file nested under app/[lang] |
| Translated content | Per-locale dictionary files, loaded via a getDictionary function |
| Avoiding prop-drilling | next/root-params, for Server Components and server utilities only |
| Static generation per locale | generateStaticParams on the root layout |
| Pluralization / formatting | Intl.NumberFormat / Intl.DateTimeFormat, or a full library like next-intl for complex cases |
| Sub-path vs. domain routing | Default to sub-path; reach for domains only with a specific business reason |
None of these pieces are exotic — locale detection is a header-parsing library, routing is a dynamic segment, localization is a lookup function, and static generation is generateStaticParams doing exactly what it does for any other route. What makes Next.js i18n feel harder than it is is that no single API ties them together for you. Once you've built it once, though, it's a pattern you can carry into every future project with almost no changes.


