Next.js Internationalization with next-intl: App Router, Locale Detection, Translated Routes, and Pluralization
Add internationalization to your Next.js App Router app with next-intl. Covers locale detection middleware, translated routes, pluralization, number/date formatting, server components, and SEO hreflang tags.
Adding internationalization to a Next.js App Router project is an architectural decision — the route structure, component patterns, and metadata generation all change once you have multiple locales. next-intl is the most mature i18n library for Next.js App Router, with full support for Server Components, typed message keys, pluralization, and rich text formatting.
This guide walks through the complete setup with locale detection, translated routes, and SEO hreflang tags.
Installation
npm install next-intl
Directory Structure
app/
[locale]/
layout.tsx # Locale-aware root layout
page.tsx # Home page
blog/
page.tsx
[slug]/
page.tsx
(auth)/
login/page.tsx
middleware.ts # Locale detection and routing
i18n/
request.ts # Server-side locale config
messages/
en.json
de.json
fr.json
es.json
🌐 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
Locale Configuration
// i18n/config.ts
export const locales = ["en", "de", "fr", "es"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
// Human-readable locale names for UI
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
de: "Deutsch",
fr: "Français",
es: "Español",
};
Middleware: Locale Detection
// middleware.ts
import createMiddleware from "next-intl/middleware";
import { locales, defaultLocale } from "@/i18n/config";
export default createMiddleware({
locales,
defaultLocale,
// Strategy: 'always' | 'as-needed' | 'never'
// 'as-needed': default locale has no prefix (/about), others prefixed (/de/about)
// 'always': all locales prefixed (/en/about, /de/about) — cleaner for SEO
localePrefix: "always",
// Locale detection order: cookie → Accept-Language header → default
localeDetection: true,
});
export const config = {
// Run middleware on all paths except static files and API routes
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
🚀 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
Server-Side Locale Config
// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { locales, defaultLocale, type Locale } from "./config";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate locale — fall back to default if invalid
if (!locale || !locales.includes(locale as Locale)) {
locale = defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
next.config.ts Integration
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
export default withNextIntl({
// Your other Next.js config
});
Message Files
// messages/en.json
{
"nav": {
"home": "Home",
"blog": "Blog",
"pricing": "Pricing",
"contact": "Contact"
},
"hero": {
"headline": "Build better software, faster",
"subheadline": "We design and ship production-ready SaaS applications for ambitious founders.",
"cta": "Start your project"
},
"pricing": {
"title": "Simple, transparent pricing",
"seats": "{count, plural, one {# seat} other {# seats}}",
"perMonth": "{price}/month",
"features": "{count, plural, one {# feature} other {# features}} included"
},
"blog": {
"readMore": "Read article",
"publishedOn": "Published on {date, date, long}",
"readTime": "{minutes, plural, one {# minute read} other {# minute read}}"
},
"errors": {
"notFound": "Page not found",
"notFoundDesc": "The page you're looking for doesn't exist or has been moved."
}
}
// messages/de.json
{
"nav": {
"home": "Startseite",
"blog": "Blog",
"pricing": "Preise",
"contact": "Kontakt"
},
"hero": {
"headline": "Bessere Software, schneller entwickeln",
"subheadline": "Wir entwickeln produktionsreife SaaS-Anwendungen für ambitionierte Gründer.",
"cta": "Projekt starten"
},
"pricing": {
"title": "Einfache, transparente Preise",
"seats": "{count, plural, one {# Platz} other {# Plätze}}",
"perMonth": "{price}/Monat",
"features": "{count, plural, one {# Funktion} other {# Funktionen}} enthalten"
},
"blog": {
"readMore": "Artikel lesen",
"publishedOn": "Veröffentlicht am {date, date, long}",
"readTime": "{minutes, plural, one {# Minute Lesezeit} other {# Minuten Lesezeit}}"
},
"errors": {
"notFound": "Seite nicht gefunden",
"notFoundDesc": "Die gesuchte Seite existiert nicht oder wurde verschoben."
}
}
App Router Layout
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales, type Locale } from "@/i18n/config";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const { locale } = await params;
// Validate locale — 404 if not in list
if (!locales.includes(locale as Locale)) notFound();
// Load messages for this locale (passed to client provider)
const messages = await getMessages();
return (
<html lang={locale} dir={locale === "ar" ? "rtl" : "ltr"}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Server Component Usage
// app/[locale]/page.tsx — Server Component, no "use client"
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
// Typed metadata using translations
export async function generateMetadata({
params,
}: {
params: { locale: string };
}): Promise<Metadata> {
const t = await getTranslations({ locale: params.locale, namespace: "hero" });
return {
title: t("headline"),
description: t("subheadline"),
};
}
export default function HomePage() {
// useTranslations works in Server Components
const t = useTranslations("hero");
const nav = useTranslations("nav");
return (
<main>
<h1>{t("headline")}</h1>
<p>{t("subheadline")}</p>
<a href="/contact">{t("cta")}</a>
</main>
);
}
Client Component Usage
// components/language-switcher.tsx
"use client";
import { useLocale } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
import { locales, LOCALE_LABELS } from "@/i18n/config";
export function LanguageSwitcher() {
const currentLocale = useLocale();
const router = useRouter();
const pathname = usePathname();
function switchLocale(newLocale: string) {
// Replace the locale segment in the current path
// Pathname: /de/blog/my-post → /en/blog/my-post
const segments = pathname.split("/");
segments[1] = newLocale; // [locale] is always the first segment
router.push(segments.join("/"));
}
return (
<select
value={currentLocale}
onChange={(e) => switchLocale(e.target.value)}
className="text-sm border border-gray-200 rounded-md px-2 py-1 bg-white"
aria-label="Select language"
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{LOCALE_LABELS[locale]}
</option>
))}
</select>
);
}
Pluralization and Formatting
// components/blog-meta.tsx
"use client";
import { useTranslations, useFormatter } from "next-intl";
interface BlogMetaProps {
publishedAt: Date;
readTimeMinutes: number;
}
export function BlogMeta({ publishedAt, readTimeMinutes }: BlogMetaProps) {
const t = useTranslations("blog");
const format = useFormatter();
return (
<div className="flex items-center gap-4 text-sm text-gray-500">
{/* Locale-aware date formatting */}
<span>
{t("publishedOn", { date: publishedAt })}
{/* en: "Published on April 17, 2027" */}
{/* de: "Veröffentlicht am 17. April 2027" */}
</span>
{/* Pluralization via ICU message format */}
<span>
{t("readTime", { minutes: readTimeMinutes })}
{/* en: "12 minute read" (singular if 1) */}
{/* de: "12 Minuten Lesezeit" */}
</span>
</div>
);
}
// Formatting numbers and currencies per locale
function PriceDisplay({ amount, currency }: { amount: number; currency: string }) {
const format = useFormatter();
return (
<span>
{format.number(amount, { style: "currency", currency })}
{/* en: "$49.00" */}
{/* de: "49,00 $" (German convention) */}
</span>
);
}
SEO: hreflang Tags
// app/[locale]/blog/[slug]/page.tsx
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import { locales } from "@/i18n/config";
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
export async function generateMetadata({
params,
}: {
params: { locale: string; slug: string };
}): Promise<Metadata> {
const t = await getTranslations({ locale: params.locale, namespace: "blog" });
// Build hreflang alternates for all locales
const languages: Record<string, string> = {};
for (const locale of locales) {
languages[locale] = `${APP_URL}/${locale}/blog/${params.slug}`;
}
// x-default points to the default locale
languages["x-default"] = `${APP_URL}/en/blog/${params.slug}`;
return {
alternates: {
languages,
},
};
// Next.js renders these as <link rel="alternate" hreflang="..." href="..." />
}
Type Safety for Message Keys
// Create global TypeScript types for messages
// types/next-intl.d.ts
import en from "@/messages/en.json";
declare module "next-intl" {
interface AppConfig {
Messages: typeof en;
}
}
// Now useTranslations("nav") is typed:
// t("home") ✅
// t("hoem") ❌ TypeScript error: "hoem" does not exist in "nav"
Cost and Timeline Estimates
| Scope | Locales | Team | Timeline | Cost Range |
|---|---|---|---|---|
| Basic i18n setup (2 locales) | 2 | 1 dev | 2–3 days | $600–1,200 |
| Full site (4 locales, with translations) | 4 | 1–2 devs | 2–3 weeks | $5,000–10,000 |
| + RTL support, locale-specific content | 4+ | 2 devs | 4–6 weeks | $12,000–25,000 |
Translation costs (not included above): Professional translation ~$0.10–0.20/word; machine translation + review ~$0.05–0.10/word.
See Also
- Next.js App Router Caching Strategies
- Next.js Performance Optimization
- Next.js Multi-Tenant Subdomain Routing
- Next.js Static Generation with ISR
- React Accessibility and ARIA Patterns
Working With Viprasol
Internationalization done right means more than running strings through Google Translate. Our team sets up next-intl with typed message keys, ICU pluralization, locale-aware date and number formatting, and hreflang metadata — so your site is genuinely localized, not just translated.
What we deliver:
- next-intl middleware with locale detection and prefix strategy
[locale]App Router structure withgenerateStaticParams- Typed message keys with TypeScript global declaration
- Pluralization and date/number formatting per locale
- Language switcher preserving current path
- hreflang alternates for all locales (SEO)
Talk to our team about internationalizing your Next.js app →
Or explore our web development services.
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.