Next.js i18n: Complete Internationalization Guide
Build multilingual Next.js apps with the App Router using locale routing, server-side translations, dynamic language switching, and SEO best practices.
What you'll learn
- ✓Setting up locale-based routing in the App Router
- ✓Loading and rendering translations on the server
- ✓Building a language switcher component
- ✓SEO optimization for multilingual sites
Prerequisites
- •Next.js App Router fundamentals
- •Basic understanding of React Server Components
The i18n Strategy in App Router
The App Router does not include a built-in i18n configuration like the Pages Router did. Instead, you build internationalization using the filesystem routing you already know: a [locale] dynamic segment wraps your entire app, and middleware detects the user’s preferred language.
This approach gives you more control and works well with Server Components because translations load on the server without shipping translation bundles to the client.
Here is the file structure you will build:
app/
[locale]/
layout.tsx
page.tsx
about/
page.tsx
dictionaries/
en.json
es.json
fr.json
de.json
middleware.ts
lib/
i18n.ts
Setting Up the Locale Configuration
Start with a central configuration file that defines your supported locales and default language:
// lib/i18n.ts
export const locales = ["en", "es", "fr", "de"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const localeNames: Record<Locale, string> = {
en: "English",
es: "Espanol",
fr: "Francais",
de: "Deutsch",
};
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale);
}
Creating Translation Dictionaries
Store your translations as JSON files. Each file maps the same keys to the translated strings:
// dictionaries/en.json
{
"nav": {
"home": "Home",
"about": "About",
"blog": "Blog",
"contact": "Contact"
},
"home": {
"title": "Welcome to Our Platform",
"subtitle": "Build something great today",
"cta": "Get Started",
"features": {
"fast": "Lightning Fast",
"fastDescription": "Optimized for speed at every level",
"secure": "Secure by Default",
"secureDescription": "Enterprise-grade security built in",
"scalable": "Infinitely Scalable",
"scalableDescription": "Grows with your business needs"
}
},
"about": {
"title": "About Us",
"description": "We are a team of passionate developers building tools for the modern web."
},
"common": {
"learnMore": "Learn More",
"backToHome": "Back to Home",
"language": "Language"
}
}
// dictionaries/es.json
{
"nav": {
"home": "Inicio",
"about": "Acerca de",
"blog": "Blog",
"contact": "Contacto"
},
"home": {
"title": "Bienvenido a Nuestra Plataforma",
"subtitle": "Construye algo grandioso hoy",
"cta": "Comenzar",
"features": {
"fast": "Ultra Rapido",
"fastDescription": "Optimizado para velocidad en todos los niveles",
"secure": "Seguro por Defecto",
"secureDescription": "Seguridad de nivel empresarial incorporada",
"scalable": "Infinitamente Escalable",
"scalableDescription": "Crece con las necesidades de tu negocio"
}
},
"about": {
"title": "Acerca de Nosotros",
"description": "Somos un equipo de desarrolladores apasionados construyendo herramientas para la web moderna."
},
"common": {
"learnMore": "Saber Mas",
"backToHome": "Volver al Inicio",
"language": "Idioma"
}
}
Loading Dictionaries on the Server
Create a function that loads the right dictionary based on the locale. Using dynamic imports keeps each translation bundle lazy-loaded:
// lib/dictionaries.ts
import type { Locale } from "./i18n";
const dictionaries = {
en: () => import("@/dictionaries/en.json").then((m) => m.default),
es: () => import("@/dictionaries/es.json").then((m) => m.default),
fr: () => import("@/dictionaries/fr.json").then((m) => m.default),
de: () => import("@/dictionaries/de.json").then((m) => m.default),
};
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>;
export async function getDictionary(locale: Locale): Promise<Dictionary> {
return dictionaries[locale]();
}
Because this runs in a Server Component, the JSON is loaded on the server and the translation data never becomes part of your client JavaScript bundle.
Middleware for Locale Detection
The middleware detects the user’s preferred locale and redirects them to the right path:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { locales, defaultLocale, isValidLocale } from "./lib/i18n";
function getPreferredLocale(request: NextRequest): string {
// Check cookie first
const cookieLocale = request.cookies.get("preferred-locale")?.value;
if (cookieLocale && isValidLocale(cookieLocale)) {
return cookieLocale;
}
// Parse Accept-Language header
const acceptLanguage = request.headers.get("accept-language");
if (acceptLanguage) {
const preferred = acceptLanguage
.split(",")
.map((lang) => {
const [code, priority] = lang.trim().split(";q=");
return {
code: code.split("-")[0].toLowerCase(),
priority: priority ? parseFloat(priority) : 1,
};
})
.sort((a, b) => b.priority - a.priority)
.find((lang) => isValidLocale(lang.code));
if (preferred) {
return preferred.code;
}
}
return defaultLocale;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if the pathname already has a locale
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) {
return NextResponse.next();
}
// Skip static files and API routes
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
pathname.includes(".")
) {
return NextResponse.next();
}
// Redirect to the preferred locale
const locale = getPreferredLocale(request);
const newUrl = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(newUrl);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"],
};
When someone visits /about, middleware redirects them to /en/about or /es/about based on their browser language or saved preference.
Building the Locale Layout
The [locale] layout wraps your entire app and provides the locale context:
// app/[locale]/layout.tsx
import { notFound } from "next/navigation";
import { locales, type Locale } from "@/lib/i18n";
import { getDictionary } from "@/lib/dictionaries";
import { Navigation } from "@/components/Navigation";
export async function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = await getDictionary(locale as Locale);
return {
title: {
template: `%s | ${dict.home.title}`,
default: dict.home.title,
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!locales.includes(locale as Locale)) {
notFound();
}
const dict = await getDictionary(locale as Locale);
return (
<html lang={locale}>
<body>
<Navigation locale={locale as Locale} dict={dict} />
<main>{children}</main>
</body>
</html>
);
}
The generateStaticParams function tells Next.js to pre-render pages for all supported locales at build time.
Page Components with Translations
Each page loads its translations on the server:
// app/[locale]/page.tsx
import { getDictionary } from "@/lib/dictionaries";
import type { Locale } from "@/lib/i18n";
import Link from "next/link";
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = await getDictionary(locale as Locale);
return (
<div className="max-w-4xl mx-auto px-4 py-16">
<section className="text-center mb-16">
<h1 className="text-5xl font-bold mb-4">{dict.home.title}</h1>
<p className="text-xl text-gray-600 mb-8">{dict.home.subtitle}</p>
<Link
href={`/${locale}/signup`}
className="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg hover:bg-blue-700"
>
{dict.home.cta}
</Link>
</section>
<section className="grid md:grid-cols-3 gap-8">
<FeatureCard
title={dict.home.features.fast}
description={dict.home.features.fastDescription}
/>
<FeatureCard
title={dict.home.features.secure}
description={dict.home.features.secureDescription}
/>
<FeatureCard
title={dict.home.features.scalable}
description={dict.home.features.scalableDescription}
/>
</section>
</div>
);
}
function FeatureCard({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<div className="border rounded-lg p-6">
<h3 className="text-xl font-semibold mb-2">{title}</h3>
<p className="text-gray-600">{description}</p>
</div>
);
}
Language Switcher Component
The language switcher lets users change their preferred language. It updates a cookie so middleware remembers the choice:
// components/LanguageSwitcher.tsx
"use client";
import { usePathname, useRouter } from "next/navigation";
import { locales, localeNames, type Locale } from "@/lib/i18n";
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname();
const router = useRouter();
function switchLocale(newLocale: Locale) {
// Replace the locale segment in the current path
const segments = pathname.split("/");
segments[1] = newLocale;
const newPath = segments.join("/");
// Set cookie for middleware to remember
document.cookie = `preferred-locale=${newLocale};path=/;max-age=${60 * 60 * 24 * 365}`;
router.push(newPath);
}
return (
<div className="relative">
<select
value={currentLocale}
onChange={(e) => switchLocale(e.target.value as Locale)}
className="appearance-none bg-white border rounded px-3 py-1.5 pr-8 text-sm"
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</div>
);
}
Handling Pluralization and Interpolation
For more complex translations with variables and plural forms, build a simple formatter:
// lib/translate.ts
type TranslateOptions = {
count?: number;
[key: string]: string | number | undefined;
};
export function t(
template: string | { one: string; other: string },
options?: TranslateOptions
): string {
let text: string;
if (typeof template === "object" && options?.count !== undefined) {
text = options.count === 1 ? template.one : template.other;
} else if (typeof template === "string") {
text = template;
} else {
text = template.other;
}
if (options) {
for (const [key, value] of Object.entries(options)) {
if (value !== undefined) {
text = text.replace(new RegExp(`{{${key}}}`, "g"), String(value));
}
}
}
return text;
}
Use it with dictionary entries that include placeholders:
{
"blog": {
"postCount": {
"one": "{{count}} post found",
"other": "{{count}} posts found"
},
"greeting": "Hello, {{name}}!"
}
}
import { t } from "@/lib/translate";
// "5 posts found"
t(dict.blog.postCount, { count: 5 });
// "1 post found"
t(dict.blog.postCount, { count: 1 });
// "Hello, Alice!"
t(dict.blog.greeting, { name: "Alice" });
SEO for Multilingual Sites
Search engines need to understand the relationship between your translated pages. Add hreflang tags and localized metadata:
// app/[locale]/layout.tsx
import { locales, type Locale } from "@/lib/i18n";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = await getDictionary(locale as Locale);
return {
title: dict.home.title,
description: dict.home.subtitle,
alternates: {
canonical: `https://mysite.com/${locale}`,
languages: Object.fromEntries(
locales.map((l) => [l, `https://mysite.com/${l}`])
),
},
openGraph: {
locale: locale,
alternateLocales: locales.filter((l) => l !== locale),
},
};
}
This generates the proper <link rel="alternate" hreflang="..."> tags that tell Google and other search engines about your translated pages.
Generating a Multilingual Sitemap
Create a sitemap that includes all locale variants of every page:
// app/sitemap.ts
import { locales } from "@/lib/i18n";
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://mysite.com";
const pages = ["", "/about", "/blog", "/contact"];
const entries: MetadataRoute.Sitemap = [];
for (const page of pages) {
for (const locale of locales) {
entries.push({
url: `${baseUrl}/${locale}${page}`,
lastModified: new Date(),
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${baseUrl}/${l}${page}`])
),
},
});
}
}
return entries;
}
Type-Safe Translations
Add type safety so you catch missing translation keys at build time:
// lib/dictionaries.ts
import type { Locale } from "./i18n";
// Import the English dictionary as the canonical type
import en from "@/dictionaries/en.json";
export type Dictionary = typeof en;
const dictionaries: Record<Locale, () => Promise<Dictionary>> = {
en: () => import("@/dictionaries/en.json").then((m) => m.default),
es: () => import("@/dictionaries/es.json").then((m) => m.default),
fr: () => import("@/dictionaries/fr.json").then((m) => m.default),
de: () => import("@/dictionaries/de.json").then((m) => m.default),
};
export async function getDictionary(locale: Locale): Promise<Dictionary> {
return dictionaries[locale]();
}
Now TypeScript will autocomplete dict.home.title and catch typos like dict.home.titl at compile time. If you add a key to en.json, TypeScript will remind you to add it to the other language files too.
Wrapping Up
Internationalization in the Next.js App Router is built on patterns you already know: dynamic route segments, Server Components, and middleware. The [locale] segment organizes your routes, middleware handles detection and redirects, and Server Components load translations without bloating the client bundle.
The type-safe dictionary approach catches missing translations early, the language switcher remembers preferences via cookies, and proper hreflang metadata ensures search engines index every language variant correctly. Start with two languages and expand from there. The architecture scales cleanly because each new language is just another JSON file and a single entry in your locale configuration.
Related articles
- Next.js Internationalization (i18n) with Next.js App Router
Set up multi-language support in Next.js App Router using route-based locale segments, dictionaries, and middleware-based locale detection.
- 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.
- Next.js Next.js API Routes: Best Practices and Patterns
Build robust Next.js API routes with proper validation, error handling, authentication, rate limiting, and testing patterns for production apps.
- Next.js Next.js Server Actions: Forms and Mutations Guide
Learn how to use Next.js Server Actions for form handling, data mutations, optimistic updates, and error handling with practical examples.