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.
Nextcraft Engineering Team
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
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.