Next.js Internationalization: next-intl, Locale Routing, Pluralization, and SEO
Implement Next.js internationalization with next-intl: locale-based routing with App Router, server-side translations in RSC, pluralization, date/number formatting, hreflang SEO, and locale detection middleware.
Internationalizing a Next.js App Router application is more involved than the old Pages Router approach — there's no built-in i18n routing in App Router, but next-intl fills that gap cleanly. It handles locale-based URL routing (/en/about, /de/uber-uns), server-side translations in React Server Components without prop drilling, pluralization, and locale-aware date/number formatting.
This post covers the full setup: directory structure, middleware for locale detection and routing, translations in RSC and Client Components, pluralization rules, locale-aware formatting, and hreflang tags for SEO.
1. Project Structure
src/
app/
[locale]/ ← All pages inside locale segment
layout.tsx
page.tsx
about/
page.tsx
blog/
[slug]/
page.tsx
i18n/
request.ts ← next-intl server config
routing.ts ← locale definitions
messages/
en.json
de.json
fr.json
es.json
middleware.ts
2. Installation and Configuration
npm install next-intl
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'de', 'fr', 'es'],
defaultLocale: 'en',
// Locale prefix strategy
localePrefix: 'as-needed', // /en/about vs /about (default locale no prefix)
// Alternatives:
// 'always' → /en/about, /de/uber-uns (explicit for all locales)
// 'never' → /about, /about (use Accept-Language header only — harder for SEO)
// Optional: custom locale paths (slug translation)
pathnames: {
'/about': {
en: '/about',
de: '/uber-uns',
fr: '/a-propos',
es: '/acerca-de',
},
'/blog/[slug]': {
en: '/blog/[slug]',
de: '/blog/[slug]',
fr: '/blog/[slug]',
es: '/blog/[slug]',
},
},
});
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate locale — fall back to default if invalid
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
// Time zone: use user preference or server default
timeZone: 'UTC',
// Number formatting defaults
formats: {
number: {
currency: { style: 'currency', minimumFractionDigits: 2 },
},
},
};
});
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
3. Middleware for Locale Routing
// middleware.ts (project root)
import createMiddleware from 'next-intl/middleware';
import { routing } from './src/i18n/routing';
import { type NextRequest, NextResponse } from 'next/server';
const intlMiddleware = createMiddleware(routing);
export default function middleware(req: NextRequest): NextResponse {
// Run next-intl middleware for locale routing
return intlMiddleware(req);
}
export const config = {
// Match all pathnames except static files and Next.js internals
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|images|fonts).*)'],
};
4. Translation Messages
// messages/en.json
{
"common": {
"loading": "Loading...",
"error": "Something went wrong",
"tryAgain": "Try again",
"save": "Save changes",
"cancel": "Cancel",
"delete": "Delete",
"confirm": "Confirm"
},
"nav": {
"home": "Home",
"about": "About",
"blog": "Blog",
"pricing": "Pricing",
"contact": "Contact us"
},
"pricing": {
"title": "Simple, transparent pricing",
"subtitle": "Choose the plan that works for your team",
"perMonth": "per month",
"startTrial": "Start free trial",
"plans": {
"starter": {
"name": "Starter",
"description": "Perfect for small teams getting started"
},
"pro": {
"name": "Pro",
"description": "For growing teams that need more power"
}
},
"seats": "{count, plural, one {# seat} other {# seats}}",
"trialDays": "{days, plural, one {# day free trial} other {# days free trial}}"
},
"blog": {
"readMore": "Read more",
"publishedOn": "Published on {date}",
"minuteRead": "{minutes, plural, one {# minute read} other {# minute read}}",
"relatedPosts": "Related posts"
},
"errors": {
"notFound": "Page not found",
"notFoundDescription": "The page you're looking for doesn't exist.",
"serverError": "Server error",
"serverErrorDescription": "Something went wrong on our end. Please try again."
}
}
// messages/de.json
{
"common": {
"loading": "Wird geladen...",
"error": "Etwas ist schiefgelaufen",
"tryAgain": "Erneut versuchen",
"save": "Änderungen speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"confirm": "Bestätigen"
},
"nav": {
"home": "Startseite",
"about": "Über uns",
"blog": "Blog",
"pricing": "Preise",
"contact": "Kontakt"
},
"pricing": {
"title": "Einfache, transparente Preise",
"subtitle": "Wählen Sie den Plan, der zu Ihrem Team passt",
"perMonth": "pro Monat",
"startTrial": "Kostenlos testen",
"seats": "{count, plural, one {# Platz} other {# Plätze}}",
"trialDays": "{days, plural, one {# Tag kostenlos} other {# Tage kostenlos}}"
},
"blog": {
"readMore": "Weiterlesen",
"publishedOn": "Veröffentlicht am {date}",
"minuteRead": "{minutes, plural, one {# Minute Lesezeit} other {# Minuten Lesezeit}}",
"relatedPosts": "Verwandte Beiträge"
},
"errors": {
"notFound": "Seite nicht gefunden",
"notFoundDescription": "Die gesuchte Seite existiert nicht.",
"serverError": "Serverfehler",
"serverErrorDescription": "Auf unserer Seite ist etwas schiefgelaufen. Bitte versuchen Sie es erneut."
}
}
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
5. Using Translations in Server Components
// src/app/[locale]/pricing/page.tsx
import { useTranslations } from 'next-intl';
import { getTranslations, getLocale } from 'next-intl/server';
import { setRequestLocale } from 'next-intl/server';
import type { Metadata } from 'next';
// Generate metadata with locale-aware title/description
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'pricing' });
return {
title: t('title'),
description: t('subtitle'),
alternates: {
// hreflang tags for SEO
languages: {
en: '/en/pricing',
de: '/de/preise',
fr: '/fr/tarifs',
es: '/es/precios',
'x-default': '/pricing',
},
},
};
}
// Server Component — useTranslations works server-side with next-intl
export default async function PricingPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Required in Next.js 15+ for static rendering with i18n
setRequestLocale(locale);
const t = useTranslations('pricing');
return (
<div className="mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold text-center text-gray-900">{t('title')}</h1>
<p className="mt-4 text-xl text-center text-gray-500">{t('subtitle')}</p>
<div className="mt-12 grid grid-cols-2 gap-8">
<PlanCard
name={t('plans.starter.name')}
description={t('plans.starter.description')}
price={29}
seats={5}
trialDays={14}
/>
<PlanCard
name={t('plans.pro.name')}
description={t('plans.pro.description')}
price={99}
seats={25}
trialDays={14}
/>
</div>
</div>
);
}
function PlanCard({
name, description, price, seats, trialDays,
}: {
name: string; description: string; price: number; seats: number; trialDays: number;
}) {
const t = useTranslations('pricing');
const locale = useLocale();
return (
<div className="rounded-xl border border-gray-200 p-6">
<h2 className="text-xl font-semibold">{name}</h2>
<p className="mt-1 text-gray-500 text-sm">{description}</p>
<p className="mt-4 text-3xl font-bold">
{/* Locale-aware number formatting */}
{new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(price)}
<span className="text-base font-normal text-gray-400"> {t('perMonth')}</span>
</p>
{/* ICU message format pluralization */}
<p className="mt-2 text-sm text-gray-600">{t('seats', { count: seats })}</p>
<p className="text-sm text-gray-600">{t('trialDays', { days: trialDays })}</p>
<button className="mt-6 w-full rounded-lg bg-blue-600 py-2 text-sm font-medium text-white">
{t('startTrial')}
</button>
</div>
);
}
6. Translations in Client Components
// src/components/LocaleSwitcher.tsx
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, usePathname } from 'next-intl/client';
import { routing } from '../../i18n/routing';
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const localeNames: Record<string, string> = {
en: 'English',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
};
function handleChange(newLocale: string) {
// Switches locale while keeping the current pathname
router.replace(pathname, { locale: newLocale });
}
return (
<select
value={locale}
onChange={(e) => handleChange(e.target.value)}
className="rounded border border-gray-300 px-2 py-1 text-sm"
aria-label="Select language"
>
{routing.locales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc] ?? loc}
</option>
))}
</select>
);
}
// Client component using translations
'use client';
import { useTranslations, useFormatter } from 'next-intl';
export function BlogCard({ post }: { post: Post }) {
const t = useTranslations('blog');
const format = useFormatter();
return (
<article className="rounded-lg border p-4">
<h3 className="font-semibold">{post.title}</h3>
<p className="text-sm text-gray-500">
{t('publishedOn', {
date: format.dateTime(new Date(post.publishedAt), {
year: 'numeric', month: 'long', day: 'numeric',
}),
})}
</p>
<p className="text-xs text-gray-400">
{t('minuteRead', { minutes: post.readTime })}
</p>
</article>
);
}
7. Layout with Locale Provider
// src/app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '../../i18n/routing';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Validate locale
if (!routing.locales.includes(locale as any)) {
notFound();
}
setRequestLocale(locale);
// Pass messages to Client Components
const messages = await getMessages();
return (
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Cost Reference
| Translation approach | Cost | Scale |
|---|---|---|
| JSON files in repo | $0 | < 1,000 keys |
| Crowdin / Phrase | $50–200/mo | Team translation workflow |
| DeepL Auto-translate | $5.49/mo + $25/M chars | AI-assisted first pass |
| Locize (next-intl native) | $10–100/mo | Real-time translation updates |
See Also
- Next.js App Router: Server Components, Streaming, and Server Actions
- Next.js Middleware: Edge Auth Guards and A/B Testing
- Next.js Image Optimization: AVIF/WebP and CDN Delivery
- React Server Actions: useActionState and Optimistic UI
- Next.js Testing Strategy: Unit, Integration, and E2E with Playwright
Working With Viprasol
Adding multiple languages to an existing Next.js App Router application, or starting a new project that needs to serve English, German, French, and Spanish from day one? We implement next-intl with locale-based routing, server-side RSC translations, ICU pluralization, locale-aware formatting, and hreflang SEO — so your app is globally ready without a full rewrite.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.