React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

i18ninternationalizationlocaletranslationsroutingnext-intl

Internationalization (i18n)

Recipe

Add multi-language support to a Next.js 15+ App Router application with locale-based routing, translation loading, locale detection via middleware, and SEO-friendly hreflang tags.

Working Example

Project Structure

app/
  [locale]/
    layout.tsx
    page.tsx
    posts/
      page.tsx
lib/
  i18n/
    config.ts
    get-dictionary.ts
    dictionaries/
      en.json
      de.json
      ja.json
middleware.ts

i18n Configuration

// lib/i18n/config.ts
export const i18nConfig = {
  defaultLocale: "en",
  locales: ["en", "de", "ja"],
} as const;
 
export type Locale = (typeof i18nConfig.locales)[number];

Translation Dictionaries

// lib/i18n/dictionaries/en.json
{
  "common": {
    "title": "My App",
    "nav": {
      "home": "Home",
      "posts": "Posts",
      "about": "About"
    }
  },
  "home": {
    "heading": "Welcome to My App",
    "description": "A modern web application"
  },
  "posts": {
    "heading": "All Posts",
    "empty": "No posts found"
  }
}
// lib/i18n/dictionaries/de.json
{
  "common": {
    "title": "Meine App",
    "nav": {
      "home": "Startseite",
      "posts": "Beiträge",
      "about": "Über uns"
    }
  },
  "home": {
    "heading": "Willkommen bei Meine App",
    "description": "Eine moderne Webanwendung"
  },
  "posts": {
    "heading": "Alle Beiträge",
    "empty": "Keine Beiträge gefunden"
  }
}

Dictionary Loader

// lib/i18n/get-dictionary.ts
import type { Locale } from "./config";
 
const dictionaries = {
  en: () => import("./dictionaries/en.json").then((m) => m.default),
  de: () => import("./dictionaries/de.json").then((m) => m.default),
  ja: () => import("./dictionaries/ja.json").then((m) => m.default),
};
 
export async function getDictionary(locale: Locale) {
  return dictionaries[locale]();
}
 
export type Dictionary = Awaited<ReturnType<typeof getDictionary>>;

Locale Detection Middleware

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { i18nConfig } from "./lib/i18n/config";
 
function getPreferredLocale(request: NextRequest): string {
  const acceptLanguage = request.headers.get("accept-language");
  if (!acceptLanguage) return i18nConfig.defaultLocale;
 
  const preferred = acceptLanguage
    .split(",")
    .map((lang) => {
      const [code, priority] = lang.trim().split(";q=");
      return {
        code: code.split("-")[0].toLowerCase(),
        priority: priority ? parseFloat(priority) : 1.0,
      };
    })
    .sort((a, b) => b.priority - a.priority);
 
  const match = preferred.find((p) =>
    i18nConfig.locales.includes(p.code as any)
  );
 
  return match?.code ?? i18nConfig.defaultLocale;
}
 
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Check if pathname already has a locale
  const pathnameHasLocale = i18nConfig.locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );
 
  if (pathnameHasLocale) return NextResponse.next();
 
  // Skip non-page paths
  if (
    pathname.startsWith("/_next") ||
    pathname.startsWith("/api") ||
    pathname.includes(".")
  ) {
    return NextResponse.next();
  }
 
  // Redirect to locale-prefixed path
  const locale = getPreferredLocale(request);
  const newUrl = new URL(`/${locale}${pathname}`, request.url);
  return NextResponse.redirect(newUrl);
}
 
export const config = {
  matcher: ["/((?!_next|api|favicon.ico).*)"],
};

Locale Layout

// app/[locale]/layout.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { i18nConfig, type Locale } from "@/lib/i18n/config";
import { getDictionary } from "@/lib/i18n/get-dictionary";
 
type Props = {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
};
 
export async function generateStaticParams() {
  return i18nConfig.locales.map((locale) => ({ locale }));
}
 
export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;
  const dict = await getDictionary(locale as Locale);
 
  return {
    title: {
      default: dict.common.title,
      template: `%s | ${dict.common.title}`,
    },
    alternates: {
      languages: Object.fromEntries(
        i18nConfig.locales.map((l) => [l, `/${l}`])
      ),
    },
  };
}
 
export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params;
 
  if (!i18nConfig.locales.includes(locale as Locale)) {
    notFound();
  }
 
  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

Translated Page

// app/[locale]/page.tsx
import { getDictionary } from "@/lib/i18n/get-dictionary";
import type { Locale } from "@/lib/i18n/config";
 
type Props = {
  params: Promise<{ locale: string }>;
};
 
export default async function HomePage({ params }: Props) {
  const { locale } = await params;
  const dict = await getDictionary(locale as Locale);
 
  return (
    <main>
      <h1>{dict.home.heading}</h1>
      <p>{dict.home.description}</p>
    </main>
  );
}

Language Switcher (Client Component)

// components/language-switcher.tsx
"use client";
 
import { usePathname, useRouter } from "next/navigation";
import { i18nConfig, type Locale } from "@/lib/i18n/config";
 
const languageNames: Record<Locale, string> = {
  en: "English",
  de: "Deutsch",
  ja: "日本語",
};
 
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
  const pathname = usePathname();
  const router = useRouter();
 
  function switchLocale(newLocale: Locale) {
    // Replace current locale in path with new locale
    const segments = pathname.split("/");
    segments[1] = newLocale;
    router.push(segments.join("/"));
  }
 
  return (
    <select
      value={currentLocale}
      onChange={(e) => switchLocale(e.target.value as Locale)}
      aria-label="Select language"
    >
      {i18nConfig.locales.map((locale) => (
        <option key={locale} value={locale}>
          {languageNames[locale]}
        </option>
      ))}
    </select>
  );
}

Deep Dive

How It Works

  • Locale-based routing uses the [locale] dynamic segment as the first path segment. All pages are nested under app/[locale]/, producing URLs like /en/posts and /de/posts.
  • Middleware detects the preferred locale from the Accept-Language header and redirects unlocalized paths to the correct locale prefix.
  • Dictionaries are loaded per-request in Server Components using dynamic import(). Each locale's translations are code-split automatically.
  • generateStaticParams pre-renders pages for all locales at build time, ensuring static generation works with locale routing.
  • hreflang tags are added via the alternates.languages field in the Metadata API, telling search engines about language alternatives.

Variations

Using next-intl (Full-Featured Library):

// next-intl setup
// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
 
export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`../messages/${locale}.json`)).default,
}));
// app/[locale]/page.tsx
import { useTranslations } from "next-intl";
 
export default function HomePage() {
  const t = useTranslations("home");
  return <h1>{t("heading")}</h1>;
}

Interpolation and Plurals:

// lib/i18n/translate.ts
type TranslationValues = Record<string, string | number>;
 
export function t(
  template: string,
  values?: TranslationValues
): string {
  if (!values) return template;
 
  return Object.entries(values).reduce(
    (result, [key, value]) =>
      result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value)),
    template
  );
}
 
// Usage:
// t("Hello, {name}! You have {count} messages.", { name: "Alice", count: 5 })
// => "Hello, Alice! You have 5 messages."

RTL Language Support:

// app/[locale]/layout.tsx
const rtlLocales = ["ar", "he"];
 
export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params;
  const dir = rtlLocales.includes(locale) ? "rtl" : "ltr";
 
  return (
    <html lang={locale} dir={dir}>
      <body>{children}</body>
    </html>
  );
}

TypeScript Notes

  • Define Locale as a union type derived from the config: type Locale = (typeof i18nConfig.locales)[number].
  • Dictionary types are inferred from the JSON structure. Use Awaited<ReturnType<typeof getDictionary>> for the full type.
  • params is Promise<{ locale: string }> in Next.js 15+. Cast to Locale after validation.
  • For deeply nested translation keys, consider a helper that provides autocomplete:
type NestedKeyOf<T> = T extends object
  ? { [K in keyof T]: K extends string
      ? T[K] extends object
        ? `${K}.${NestedKeyOf<T[K]>}`
        : K
      : never
    }[keyof T]
  : never;

Gotchas

  1. Middleware runs on every request. The locale detection regex in config.matcher must exclude static assets (_next, files with extensions) to avoid unnecessary redirects.
  2. generateStaticParams must return all locales. Missing locales will produce 404s in production unless dynamicParams is enabled.
  3. Client Components cannot call getDictionary directly because it uses import() with server-only modules. Pass translations as props from a Server Component, or use a library like next-intl that provides a client-side hook.
  4. Default locale handling varies. Some prefer /en/about (all locales prefixed), others prefer /about for the default and /de/about for others. The middleware approach above always prefixes. To hide the default locale, rewrite instead of redirect.
  5. Date, number, and currency formatting should use Intl.DateTimeFormat and Intl.NumberFormat with the current locale, not string-based dictionaries.
  6. Large translation files increase bundle size. Use dynamic import() to code-split per locale and avoid loading all languages at once.

Alternatives

ApproachProsCons
Manual [locale] routing + JSONNo dependencies, full controlManual interpolation, no plurals
next-intlFull-featured, RSC support, type-safeExtra dependency
i18next + react-i18nextHuge ecosystem, matureDesigned for client-side, SSR needs adapters
Paraglide.js (inlang)Compile-time, tiny runtimeNewer, smaller community
Crowdin or LokaliseTranslation management platformCost, external tooling

FAQs

How does locale-based routing work with the [locale] dynamic segment?
  • All pages are nested under app/[locale]/, producing URLs like /en/posts and /de/posts.
  • The locale param is extracted from the URL and used to load the correct dictionary.
  • generateStaticParams pre-renders pages for all configured locales at build time.
How does the middleware detect the user's preferred language?
  • It reads the Accept-Language header sent by the browser.
  • It parses the header into language codes with priority weights and sorts by priority.
  • The first matching locale from the config is used; otherwise the default locale is returned.
Why are dictionaries loaded with dynamic import() instead of static imports?
  • Dynamic import() enables automatic code-splitting per locale.
  • Only the requested locale's translations are loaded, not all languages at once.
  • This keeps bundle sizes small, especially with many locales.
Gotcha: What happens if generateStaticParams does not include a locale?
  • That locale will produce a 404 in production unless dynamicParams is enabled.
  • Always return all configured locales from generateStaticParams.
  • Missing locales will silently fail with no build-time warning.
Can Client Components call getDictionary directly?
  • No, because getDictionary uses server-side dynamic import().
  • Pass translations as props from a parent Server Component to the Client Component.
  • Alternatively, use a library like next-intl that provides a client-side useTranslations hook.
How do you add hreflang tags for SEO with the Metadata API?
export async function generateMetadata() {
  return {
    alternates: {
      languages: {
        en: "/en",
        de: "/de",
        ja: "/ja",
      },
    },
  };
}
  • The alternates.languages field generates <link rel="alternate" hreflang="..."> tags.
How would you type the Locale type derived from the config in TypeScript?
export const i18nConfig = {
  defaultLocale: "en",
  locales: ["en", "de", "ja"],
} as const;
 
export type Locale = (typeof i18nConfig.locales)[number];
// Result: "en" | "de" | "ja"
  • The as const assertion is critical; without it, the type widens to string[].
How do you type the dictionary return value in TypeScript?
export type Dictionary = Awaited<
  ReturnType<typeof getDictionary>
>;
  • The type is inferred from the JSON structure automatically.
  • No manual interface definition is needed.
Gotcha: Why must the middleware matcher exclude static assets and API routes?
  • Middleware runs on every request matching the pattern.
  • Without exclusions, requests for _next/static, images, and API routes would be redirected to locale-prefixed paths.
  • The matcher ["/((?!_next|api|favicon.ico).*)"] prevents unnecessary redirects.
How do you handle RTL (right-to-left) languages like Arabic or Hebrew?
const rtlLocales = ["ar", "he"];
const dir = rtlLocales.includes(locale) ? "rtl" : "ltr";
 
return (
  <html lang={locale} dir={dir}>
    <body>{children}</body>
  </html>
);
  • Set the dir attribute on <html> based on the locale.
What is the difference between redirecting and rewriting for the default locale?
  • Redirecting /about to /en/about always shows the locale prefix in the URL.
  • Rewriting serves /en/about content at /about without changing the URL.
  • Hiding the default locale (rewrite) is common; showing it (redirect) is simpler to implement.
How does the next-intl library simplify i18n compared to the manual approach?
  • It provides useTranslations for Client Components without prop-drilling.
  • It handles interpolation, plurals, and number/date formatting out of the box.
  • It integrates with the App Router and Server Components natively.