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 underapp/[locale]/, producing URLs like/en/postsand/de/posts. - Middleware detects the preferred locale from the
Accept-Languageheader 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. generateStaticParamspre-renders pages for all locales at build time, ensuring static generation works with locale routing.- hreflang tags are added via the
alternates.languagesfield 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
Localeas 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. paramsisPromise<{ locale: string }>in Next.js 15+. Cast toLocaleafter 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
- Middleware runs on every request. The locale detection regex in
config.matchermust exclude static assets (_next, files with extensions) to avoid unnecessary redirects. generateStaticParamsmust return all locales. Missing locales will produce 404s in production unlessdynamicParamsis enabled.- Client Components cannot call
getDictionarydirectly because it usesimport()with server-only modules. Pass translations as props from a Server Component, or use a library likenext-intlthat provides a client-side hook. - Default locale handling varies. Some prefer
/en/about(all locales prefixed), others prefer/aboutfor the default and/de/aboutfor others. The middleware approach above always prefixes. To hide the default locale, rewrite instead of redirect. - Date, number, and currency formatting should use
Intl.DateTimeFormatandIntl.NumberFormatwith the current locale, not string-based dictionaries. - Large translation files increase bundle size. Use dynamic
import()to code-split per locale and avoid loading all languages at once.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
Manual [locale] routing + JSON | No dependencies, full control | Manual interpolation, no plurals |
| next-intl | Full-featured, RSC support, type-safe | Extra dependency |
| i18next + react-i18next | Huge ecosystem, mature | Designed for client-side, SSR needs adapters |
| Paraglide.js (inlang) | Compile-time, tiny runtime | Newer, smaller community |
| Crowdin or Lokalise | Translation management platform | Cost, 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/postsand/de/posts. - The
localeparam is extracted from the URL and used to load the correct dictionary. generateStaticParamspre-renders pages for all configured locales at build time.
How does the middleware detect the user's preferred language?
- It reads the
Accept-Languageheader 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
dynamicParamsis 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
getDictionaryuses server-side dynamicimport(). - Pass translations as props from a parent Server Component to the Client Component.
- Alternatively, use a library like
next-intlthat provides a client-sideuseTranslationshook.
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.languagesfield 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 constassertion is critical; without it, the type widens tostring[].
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
dirattribute on<html>based on the locale.
What is the difference between redirecting and rewriting for the default locale?
- Redirecting
/aboutto/en/aboutalways shows the locale prefix in the URL. - Rewriting serves
/en/aboutcontent at/aboutwithout 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
useTranslationsfor 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.
Related
- SEO - hreflang tags and locale-specific metadata
- Authentication - locale-aware auth redirects
- Deployment - serving locale-specific builds
- Next.js Internationalization Docs