Skip to content
C Codeloom
Next.js

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.

·4 min read · By Codeloom
Intermediate 9 min read

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:

  1. Routing: every URL is prefixed with a locale, like /en/about or /de/uber-uns.
  2. Detection: middleware figures out which locale to use when the user lands on /.
  3. 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|
             +----------------------+
Locale routing flow

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 hreflang tags. 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-US vs en) inconsistently between routing and Intl. 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/pricing should 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-start from 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.