Next.js Internationalization: A Hands-on Tutorial
Add multi-language support to a Next.js App Router project with locale routing, message catalogs, and SEO-friendly URLs that scale to dozens of languages.
What you'll learn
- ✓How to structure locale-prefixed routes
- ✓How middleware detects user language
- ✓Loading message catalogs lazily
- ✓Formatting dates and numbers per locale
- ✓Avoiding common SEO pitfalls
Prerequisites
- •Familiar with HTTP and JS
- •Comfort with the App Router
What and Why
Internationalization (i18n) is the work of making your app speak more than one language. It is bigger than translation: you also need to handle date formats, number separators, right-to-left layouts, and URLs that search engines can index per locale. Next.js does not ship a built-in i18n library in the App Router any more, but it gives you the routing primitives you need to build one with very little code.
Done right, i18n quietly doubles or triples your addressable users without doubling your code.
Mental Model
Think of i18n as three layers stacked on top of each other:
- Routing: every URL is prefixed with a locale, like
/en/aboutor/de/uber-uns. - Detection: middleware figures out which locale to use when the user lands on
/. - Rendering: components read messages from a catalog keyed by locale.
The router is the foundation. Once URLs carry the locale, everything else (analytics, sharing, SEO) gets it for free.
Hands-on Example
Let us build an English/German site with auto-detection. The folder layout puts everything under a [locale] segment.
Request / Middleware /en or /de
+----------+ +------------------+ +----------+
| browser | -> | detect Accept- | -> | redirect |
| sends / | | Language header | | to /en |
+----------+ +------------------+ +----------+
|
v
+----------------------+
| app/[locale]/page.tsx|
+----------------------+ The middleware:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
const LOCALES = ["en", "de"] as const;
const DEFAULT = "en";
function pickLocale(req: NextRequest): string {
const header = req.headers.get("accept-language") ?? "";
const preferred = header.split(",")[0]?.split("-")[0];
return LOCALES.includes(preferred as any) ? preferred! : DEFAULT;
}
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (LOCALES.some((l) => pathname.startsWith(`/${l}`))) return;
const locale = pickLocale(req);
return NextResponse.redirect(new URL(`/${locale}${pathname}`, req.url));
}
export const config = { matcher: ["/((?!_next|api|favicon.ico).*)"] };
The layout reads the locale param and exposes it via context:
// app/[locale]/layout.tsx
import { notFound } from "next/navigation";
const LOCALES = ["en", "de"] as const;
export default async function LocaleLayout({
children,
params,
}: { children: React.ReactNode; params: { locale: string } }) {
if (!LOCALES.includes(params.locale as any)) notFound();
const messages = (await import(`@/messages/${params.locale}.json`)).default;
return (
<html lang={params.locale}>
<body>
<I18nProvider locale={params.locale} messages={messages}>
{children}
</I18nProvider>
</body>
</html>
);
}
Now a page can call t("home.title") and get the translated string. Date and number formatting uses the platform Intl API, which is free and accurate:
new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(new Date());
new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(42);
Common Pitfalls
- Letting the cookie or session decide the URL without redirecting. Search engines need a stable URL per language, not a personalized one.
- Forgetting
hreflangtags. Without them, Google may show the wrong locale to users from the wrong region. - Hardcoding strings in components. The first untranslated label always slips through; lint rules help catch them.
- Loading every message catalog in one bundle. Use dynamic
import()keyed by locale to keep payloads small. - Mixing locale codes (
en-USvsen) inconsistently between routing andIntl. Pick one and stick to it. - Translating route slugs but forgetting to update the sitemap, breaking discovery.
Practical Tips
- Treat translation files as source code. Review them in pull requests, not in a separate CMS.
- Use ICU MessageFormat for plurals and gender. Simple key/value works until you hit “1 item” vs “5 items”.
- Generate a typed
t()function from your catalog. Misspelled keys become compile-time errors. - Provide a language switcher that preserves the current path:
/en/pricingshould toggle to/de/pricing. - Add e2e tests that visit a sample of routes in each locale. They catch missing translations and layout breakage.
- For RTL languages, use logical CSS properties like
padding-inline-startfrom the start.
Wrap-up
i18n in Next.js is mostly a thoughtful application of middleware, dynamic segments, and the platform Intl API. You do not need a heavy library to get URLs, detection, and formatting right; you need a small amount of glue and a clear convention. Start with two locales, get the workflow smooth, and adding the next ten becomes a translation task rather than an engineering one.
Related articles
- Next.js Next.js Dynamic Routes and Catch-All Segments Explained
Master dynamic routes, catch-all segments, and optional catch-alls in Next.js. Learn how the file-based router maps URLs to components with concrete examples.
- Next.js Next.js Caching Strategies Explained
Walk through the four caching layers in Next.js App Router and learn how to choose static, ISR, dynamic, or per-request fetch caching.
- Next.js Next.js Data Fetching Patterns: A Practical Guide
Compare the main Next.js data fetching strategies and learn when to use server components, route handlers, SWR, or static generation in your apps.
- Next.js Next.js Environment Variables and Secrets
Learn how Next.js loads environment variables, when they are exposed to the browser, and how to keep secrets out of your client bundle.