Internationalization in Next.js: Building for a Global Audience
Internationalization in the App Router works differently than in Pages Router. Here's how to implement locale routing, translations, and locale-aware formatting correctly.
The i18n Landscape in Next.js App Router
The App Router doesn't have built-in i18n routing (unlike Pages Router's i18n config). Instead, it provides the primitives — dynamic route segments and middleware — that you use to build your own routing layer. Most teams either:
- Use a dynamic
[locale]segment for all routes - Implement locale routing in middleware
The dynamic segment approach is more explicit and integrates cleanly with Next.js's file-system routing.
The Folder Structure
app/
├── [locale]/
│ ├── layout.tsx — Locale-aware root layout
│ ├── page.tsx — Homepage
│ ├── blog/
│ │ ├── page.tsx — Blog index
│ │ └── [slug]/
│ │ └── page.tsx — Blog post
│ └── about/
│ └── page.tsx
├── api/ — API routes (no locale prefix)
└── middleware.ts — Locale detection and redirect
All user-facing pages live under [locale]. API routes stay at the root.
Middleware: Auto-Detecting the Locale
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const supportedLocales = ['en', 'de', 'fr', 'es'];
const defaultLocale = 'en';
function detectLocale(request: NextRequest): string {
// 1. Check for explicit locale in cookie (user's preference)
const cookieLocale = request.cookies.get('locale')?.value;
if (cookieLocale && supportedLocales.includes(cookieLocale)) {
return cookieLocale;
}
// 2. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language') ?? '';
const preferredLocale = acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].trim().split('-')[0])
.find(lang => supportedLocales.includes(lang));
return preferredLocale ?? defaultLocale;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Skip API routes, static files
if (pathname.startsWith('/api') || pathname.includes('.')) {
return NextResponse.next();
}
// Check if pathname already has a supported locale
const hasLocale = supportedLocales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (hasLocale) return NextResponse.next();
// Redirect to locale-prefixed URL
const locale = detectLocale(request);
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
The Translation Layer
For translation management, next-intl is the most fully-featured library for App Router:
npm install next-intl
// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
}));
// messages/en.json
{
"nav": {
"home": "Home",
"services": "Services",
"pricing": "Pricing"
},
"hero": {
"headline": "Build faster. Ship better.",
"cta": "Get started"
},
"pricing": {
"title": "Simple, transparent pricing",
"monthly": "Monthly",
"annual": "Annual",
"save": "Save {percent}%"
}
}
// messages/de.json
{
"nav": {
"home": "Startseite",
"services": "Leistungen",
"pricing": "Preise"
},
"hero": {
"headline": "Schneller bauen. Besser liefern.",
"cta": "Jetzt starten"
}
}
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const { locale } = await params;
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
// In Server Components — direct import
import { useTranslations } from 'next-intl';
export default function HeroSection() {
const t = useTranslations('hero');
return (
<section>
<h1>{t('headline')}</h1>
<a href="/signup">{t('cta')}</a>
</section>
);
}
Locale-Aware Metadata
// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: { locale: string };
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'meta' });
return {
title: t('homepageTitle'),
description: t('homepageDescription'),
alternates: {
canonical: `/${locale}`,
languages: {
'en': '/en',
'de': '/de',
'fr': '/fr',
},
},
};
}
The alternates.languages generates <link rel="alternate" hreflang="..."> tags — critical for international SEO. Search engines use these to serve the correct locale version to users in different countries.
Number and Date Formatting
Never hardcode number formats. Use the Intl API or next-intl's formatting utilities:
// Server Component
import { useFormatter } from 'next-intl';
export function PriceDisplay({ amount }: { amount: number }) {
const format = useFormatter();
return (
<span>
{format.number(amount, {
style: 'currency',
currency: 'USD',
})}
</span>
);
}
Different locales format currency differently:
- US:
$1,234.56 - Germany:
1.234,56 $(dot for thousands, comma for decimal, currency after) - France:
1 234,56 $(space for thousands)
The Intl.NumberFormat API handles all of this correctly by locale.
Static Generation for Localized Pages
Generate all locale variants at build time:
// app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
const locales = ['en', 'de', 'fr'];
const slugs = await getAllPostSlugs();
return locales.flatMap(locale =>
slugs.map(slug => ({ locale, slug }))
);
}
This generates /en/blog/my-post, /de/blog/my-post, and /fr/blog/my-post at build time — all statically cached, no per-request rendering needed.
The Translation Management Workflow
At scale, JSON files in the repo don't work — translators can't edit code, and tracking what needs translation becomes difficult. The solutions:
- Crowdin / Phrase: Professional translation management platforms with Git integration
- Tolgee: Open-source, self-hostable translation management
- Lingo: Simpler, developer-friendly option with a good free tier
The workflow: export English strings to the platform, translators work there, a CI step pulls updated translations and commits them to the repo. Engineers never touch translation files directly.
Continue reading
Related articles
Rate Limiting in Next.js: Protecting Your API Routes
How to implement production-grade rate limiting in Next.js — with Middleware-level protection, per-user limits, and distributed rate limiting using Upstash Redis.
EngineeringNext.js Parallel Routes and Intercepting Routes: A Complete Guide
Parallel routes and intercepting routes are among the most powerful App Router primitives. This guide explains what they do, when to use them, and how to avoid the common pitfalls.
EngineeringVercel vs Netlify vs AWS Amplify for Next.js in 2026
A practical comparison of the three most common Next.js hosting platforms — Vercel, Netlify, and AWS Amplify — with real cost and capability trade-offs.
Stay informed
Get our monthly deep dives.
Engineering, design, and growth insights — once a month. No spam.
Browse all resources