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.
What you'll learn
- ✓How to structure locale-based routing with [locale] segments
- ✓Setting up middleware for automatic locale detection
- ✓Loading translation dictionaries on the server
- ✓Building a language switcher component
- ✓Handling SEO with hreflang tags and alternate URLs
Prerequisites
- •Working knowledge of the [Next.js App Router](/blog/nextjs-app-router-basics)
- •Understanding of [Next.js middleware](/blog/nextjs-middleware-basics)
The App Router does not include built-in i18n routing like the Pages Router did. Instead, you build it yourself using dynamic route segments, middleware, and server-side dictionary loading. This approach is more flexible and gives you full control over how translations are loaded and rendered.
Route structure with [locale]
Wrap your entire app in a [locale] dynamic segment:
app/
[locale]/
layout.tsx
page.tsx
about/
page.tsx
blog/
page.tsx
middleware.ts
Every route now starts with a locale prefix: /en, /fr, /de, etc.
// app/[locale]/layout.tsx
import type { ReactNode } from 'react';
const locales = ['en', 'fr', 'de', 'es'] as const;
type Locale = (typeof locales)[number];
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
Locale detection middleware
Use middleware to detect the user’s preferred language and redirect them to the correct locale prefix:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
const locales = ['en', 'fr', 'de', 'es'];
const defaultLocale = 'en';
function getLocale(request: NextRequest): string {
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const languages = new Negotiator({ headers }).languages();
try {
return match(languages, locales, defaultLocale);
} catch {
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();
// Redirect to the detected locale
const locale = getLocale(request);
const url = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(url);
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
};
Install the dependencies:
npm install @formatjs/intl-localematcher negotiator
npm install -D @types/negotiator
Now visiting /about redirects to /en/about (or /fr/about if the browser’s language is French).
Translation dictionaries
Create JSON dictionary files for each locale:
dictionaries/
en.json
fr.json
de.json
es.json
// dictionaries/en.json
{
"nav": {
"home": "Home",
"about": "About",
"blog": "Blog",
"contact": "Contact"
},
"home": {
"title": "Welcome to our site",
"subtitle": "Build something amazing",
"cta": "Get Started"
},
"about": {
"title": "About Us",
"description": "We build tools for developers."
}
}
// dictionaries/fr.json
{
"nav": {
"home": "Accueil",
"about": "A propos",
"blog": "Blog",
"contact": "Contact"
},
"home": {
"title": "Bienvenue sur notre site",
"subtitle": "Construisez quelque chose d'incroyable",
"cta": "Commencer"
},
"about": {
"title": "A propos de nous",
"description": "Nous construisons des outils pour les developpeurs."
}
}
Loading dictionaries on the server
Create a utility function to load the correct dictionary:
// lib/dictionaries.ts
import type { Locale } from '@/lib/i18n-config';
const dictionaries = {
en: () => import('@/dictionaries/en.json').then((m) => m.default),
fr: () => import('@/dictionaries/fr.json').then((m) => m.default),
de: () => import('@/dictionaries/de.json').then((m) => m.default),
es: () => import('@/dictionaries/es.json').then((m) => m.default),
};
export async function getDictionary(locale: Locale) {
return dictionaries[locale]();
}
Use it in server components:
// app/[locale]/page.tsx
import { getDictionary } from '@/lib/dictionaries';
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = await getDictionary(locale as 'en' | 'fr' | 'de' | 'es');
return (
<main className="max-w-4xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold">{dict.home.title}</h1>
<p className="mt-4 text-xl text-gray-600">{dict.home.subtitle}</p>
<button className="mt-6 rounded bg-blue-600 px-6 py-3 text-white">
{dict.home.cta}
</button>
</main>
);
}
Because this runs on the server, the entire dictionary is never sent to the client. Only the rendered HTML reaches the browser.
Building a language switcher
'use client';
import { usePathname, useRouter } from 'next/navigation';
const locales = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Francais' },
{ code: 'de', label: 'Deutsch' },
{ code: 'es', label: 'Espanol' },
];
export function LanguageSwitcher({ currentLocale }: { currentLocale: string }) {
const pathname = usePathname();
const router = useRouter();
function switchLocale(newLocale: string) {
// Replace the locale segment in the current path
const segments = pathname.split('/');
segments[1] = newLocale;
router.push(segments.join('/'));
}
return (
<select
value={currentLocale}
onChange={(e) => switchLocale(e.target.value)}
className="rounded border px-2 py-1 text-sm"
>
{locales.map((locale) => (
<option key={locale.code} value={locale.code}>
{locale.label}
</option>
))}
</select>
);
}
Pass the current locale from a server component:
// app/[locale]/layout.tsx
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<html lang={locale}>
<body>
<header className="flex justify-between p-4">
<nav>{/* navigation links */}</nav>
<LanguageSwitcher currentLocale={locale} />
</header>
{children}
</body>
</html>
);
}
SEO: hreflang and alternate URLs
Add hreflang tags so search engines know about your translated pages:
// app/[locale]/layout.tsx
import type { Metadata } from 'next';
const locales = ['en', 'fr', 'de', 'es'];
const baseUrl = 'https://example.com';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
return {
alternates: {
canonical: `${baseUrl}/${locale}`,
languages: Object.fromEntries(
locales.map((l) => [l, `${baseUrl}/${l}`])
),
},
};
}
This generates the proper <link rel="alternate" hreflang="..."> tags in the document head.
Persisting locale preference
Store the user’s chosen locale in a cookie so they do not get redirected every time:
// In your language switcher
function switchLocale(newLocale: string) {
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`;
const segments = pathname.split('/');
segments[1] = newLocale;
router.push(segments.join('/'));
}
Update the middleware to check the cookie first:
function getLocale(request: NextRequest): string {
// Check cookie first
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && locales.includes(cookieLocale)) {
return cookieLocale;
}
// Fall back to Accept-Language header
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const languages = new Negotiator({ headers }).languages();
try {
return match(languages, locales, defaultLocale);
} catch {
return defaultLocale;
}
}
Type-safe translations
Add TypeScript types to your dictionaries to catch missing keys at compile time:
// lib/i18n-config.ts
export const i18n = {
defaultLocale: 'en',
locales: ['en', 'fr', 'de', 'es'],
} as const;
export type Locale = (typeof i18n)['locales'][number];
// Generate types from the English dictionary
type Dictionary = typeof import('@/dictionaries/en.json');
export type { Dictionary };
Now your getDictionary return type is inferred from the JSON structure, and accessing dict.home.typo produces a TypeScript error.
Common pitfalls
Forgetting to exclude static assets from middleware. Without the matcher config, your middleware runs on every request including images and CSS, causing unnecessary redirects.
Not generating static params. Without generateStaticParams, your locale pages are dynamically rendered on every request. Generate params for all locales to enable static generation.
Hardcoded strings in client components. If a client component needs translated strings, pass them as props from a server component. Do not import dictionaries on the client side as it bundles all translations.
Internationalization with the App Router requires more manual setup than the Pages Router, but the result is a cleaner architecture. Server-side dictionary loading keeps bundles small, middleware handles detection and redirects, and the [locale] segment provides a clear URL structure that search engines understand.
Related articles
- Next.js 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.
- 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.
- Next.js Next.js Streaming and Loading UI: Instant Pages
Build instant-loading Next.js pages with streaming, Suspense boundaries, loading.tsx files, and skeleton UIs for the best user experience.