//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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
// lib/i18n/config.ts
export const i18nConfig = {
defaultLocale: "en",
locales: ["en", "de", "ja"],
} as const;
export type Locale = (typeof i18nConfig.locales)[number];// 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"
}
}// 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>>;// 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).*)"],
};// 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>
);
}// 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>
);
}// 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>
);
}[locale] dynamic segment as the first path segment. All pages are nested under app/[locale]/, producing URLs like /en/posts and /de/posts.Accept-Language header and redirects unlocalized paths to the correct locale prefix.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.alternates.languages field in the Metadata API, telling search engines about language alternatives.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>
);
}Locale as a union type derived from the config: type Locale = (typeof i18nConfig.locales)[number].Awaited<ReturnType<typeof getDictionary>> for the full type.params is Promise<{ locale: string }> in Next.js 15+. Cast to Locale after validation.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;config.matcher must exclude static assets (_next, files with extensions) to avoid unnecessary redirects.generateStaticParams must return all locales. Missing locales will produce 404s in production unless dynamicParams is enabled.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./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.Intl.DateTimeFormat and Intl.NumberFormat with the current locale, not string-based dictionaries.import() to code-split per locale and avoid loading all languages at once.| 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 |
app/[locale]/, producing URLs like /en/posts and /de/posts.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.Accept-Language header sent by the browser.import() enables automatic code-splitting per locale.dynamicParams is enabled.generateStaticParams.getDictionary uses server-side dynamic import().next-intl that provides a client-side useTranslations hook.export async function generateMetadata() {
return {
alternates: {
languages: {
en: "/en",
de: "/de",
ja: "/ja",
},
},
};
}alternates.languages field generates <link rel="alternate" hreflang="..."> tags.export const i18nConfig = {
defaultLocale: "en",
locales: ["en", "de", "ja"],
} as const;
export type Locale = (typeof i18nConfig.locales)[number];
// Result: "en" | "de" | "ja"as const assertion is critical; without it, the type widens to string[].export type Dictionary = Awaited<
ReturnType<typeof getDictionary>
>;_next/static, images, and API routes would be redirected to locale-prefixed paths.["/((?!_next|api|favicon.ico).*)"] prevents unnecessary redirects.const rtlLocales = ["ar", "he"];
const dir = rtlLocales.includes(locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);dir attribute on <html> based on the locale./about to /en/about always shows the locale prefix in the URL./en/about content at /about without changing the URL.useTranslations for Client Components without prop-drilling.Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥