Internationalization in Next.js App Router: Locale Detection, Dynamic Routing, and Content Management at Scale
Internationalization in the Next.js App Router requires more than wrapping strings in a translation function. This guide covers middleware-based locale detection, URL strategies, dynamic routing with locale segments, translation file management, RTL support, and SEO implications with concrete TypeScript examples.
Most i18n implementations fail in the same place: they treat internationalization as a translation layer pasted on top of a finished app. What you get is a routing mess, inconsistent locale detection, broken SEO, and a content pipeline your team cannot maintain. The App Router changes how you structure i18n at the framework level, and the patterns that worked with the Pages Router do not map cleanly. This article covers the full architecture: how to detect and negotiate locale in middleware, which URL strategy to pick, how to structure routing with [locale] segments, how to manage translation files at scale, RTL layout considerations, and SEO implementation with hreflang and canonical URLs.
Locale Detection in Middleware
The App Router does not have built-in i18n routing the way the Pages Router did with next.config.js i18n options. You own the routing logic, which is more work upfront but gives you full control. The right place for locale detection is middleware, which runs on every request before the route resolves.
Locale negotiation follows a clear priority order: explicit URL prefix first, then a cookie (for remembered preference), then the Accept-Language header, then a fallback default.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
const SUPPORTED_LOCALES = ["en", "es", "fr", "ar"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
const DEFAULT_LOCALE: Locale = "en";
function parseAcceptLanguage(header: string): Locale | null {
// Accept-Language: en-US,en;q=0.9,es;q=0.8
const entries = header.split(",").map((entry) => {
const [lang, q] = entry.trim().split(";q=");
return { lang: lang.split("-")[0].toLowerCase(), q: q ? parseFloat(q) : 1.0 };
});
entries.sort((a, b) => b.q - a.q);
for (const { lang } of entries) {
const match = SUPPORTED_LOCALES.find((l) => l === lang);
if (match) return match;
}
return null;
}
function detectLocale(request: NextRequest): Locale {
const { pathname } = request.nextUrl;
// 1. URL prefix already present
const urlLocale = SUPPORTED_LOCALES.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (urlLocale) return urlLocale;
// 2. Cookie preference
const cookieLocale = request.cookies.get("NEXT_LOCALE")?.value as Locale | undefined;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) return cookieLocale;
// 3. Accept-Language header
const acceptLanguage = request.headers.get("accept-language");
if (acceptLanguage) {
const negotiated = parseAcceptLanguage(acceptLanguage);
if (negotiated) return negotiated;
}
return DEFAULT_LOCALE;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip static assets and API routes
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
pathname.includes(".")
) {
return NextResponse.next();
}
const hasLocalePrefix = SUPPORTED_LOCALES.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (!hasLocalePrefix) {
const locale = detectLocale(request);
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(redirectUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
One thing worth noting: the redirect approach means every first request without a locale prefix gets a round-trip redirect. If you care about TTFB for non-returning visitors, consider using NextResponse.rewrite instead of NextResponse.redirect for the default locale. The tradeoff is that the URL bar will not show the locale prefix for default-locale users, which can cause confusion if you later want canonical URLs to include the prefix consistently.
URL Strategy: Subpath vs Subdomain
There are two common URL shapes for localized content:
- Subpath:
example.com/en/pricing,example.com/es/pricing - Subdomain:
en.example.com/pricing,es.example.com/pricing
For most applications, subpath is the right choice. It is simpler to deploy, shares cookies and auth state across locales, and is easier for search engines to associate content as part of the same domain. Subdomain routing requires separate cookie configuration, separate SSL certificates per subdomain (though wildcard certs handle this), and more DNS infrastructure.
The case for subdomains is geographic CDN routing: you can point jp.example.com to an origin in Tokyo and eu.example.com to Frankfurt at the DNS level. If latency is that critical, a subdomain approach gives you cleaner traffic routing without relying on the CDN’s geo-routing rules. For most SaaS products, this is not the constraint.
Subpath with the default locale either hidden or shown is the third axis. Hiding /en for default-locale users (example.com/pricing instead of example.com/en/pricing) is tempting but creates complications: canonical URL generation becomes inconsistent, and hreflang alternates need special handling. It is cleaner to include the locale prefix for all locales including the default.
Dynamic Routing with [locale] Segments
With the locale prefix enforced in middleware, you restructure your app/ directory under a [locale] segment:
app/
[locale]/
layout.tsx
page.tsx
pricing/
page.tsx
blog/
[slug]/
page.tsx
The layout.tsx at the [locale] level is where you set the lang attribute on the <html> element and initialize your translation provider:
// app/[locale]/layout.tsx
import { notFound } from "next/navigation";
import { ReactNode } from "react";
const SUPPORTED_LOCALES = ["en", "es", "fr", "ar"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
type Props = {
children: ReactNode;
params: { locale: string };
};
export function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = params;
if (!SUPPORTED_LOCALES.includes(locale as Locale)) {
notFound();
}
const dir = locale === "ar" ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);
}
The notFound() call is important: without it, any string in that URL segment resolves to a route, including invalid locales like /zz/pricing. The generateStaticParams export pre-generates locale-aware static pages at build time for SSG routes.
Translation Management with next-intl
The most production-ready library for App Router i18n is next-intl. It handles server components, client components, and middleware without requiring a context provider that leaks across server/client boundaries.
Organize translation files as JSON namespaces. Flat JSON with all keys in one file per locale does not scale past a few hundred strings. Namespace by feature or route instead:
messages/
en/
common.json
pricing.json
blog.json
es/
common.json
pricing.json
blog.json
// messages/en/pricing.json
{
"hero": {
"title": "Simple, transparent pricing",
"subtitle": "No hidden fees. Cancel any time."
},
"plans": {
"free": {
"name": "Free",
"cta": "Get started"
},
"pro": {
"name": "Pro",
"cta": "Start free trial",
"badge": "Most popular"
}
}
}
Configure next-intl to load only the namespaces a route needs:
// i18n.ts
import { getRequestConfig } from "next-intl/server";
import { notFound } from "next/navigation";
const SUPPORTED_LOCALES = ["en", "es", "fr", "ar"];
export default getRequestConfig(async ({ locale }) => {
if (!SUPPORTED_LOCALES.includes(locale)) notFound();
return {
messages: (await import(`./messages/${locale}/common.json`)).default,
};
});
Loading all namespaces on every route is a common mistake. A pricing page does not need blog translations. Lazy-load namespaces at the page level:
// app/[locale]/pricing/page.tsx
import { getTranslations } from "next-intl/server";
type Props = {
params: { locale: string };
};
export default async function PricingPage({ params: { locale } }: Props) {
const t = await getTranslations({ locale, namespace: "pricing" });
return (
<section>
<h1>{t("hero.title")}</h1>
<p>{t("hero.subtitle")}</p>
</section>
);
}
For client components that need translations, next-intl exposes a useTranslations hook, but the translations must be passed down from a server boundary or configured via the provider. Mixing server and client translation loading is the most common source of hydration mismatches.
RTL Support
Arabic, Hebrew, Persian, and Urdu are RTL. The dir="rtl" attribute on <html> is step one, but layout reflow requires more work.
CSS logical properties are the right abstraction. Instead of margin-left and margin-right, use margin-inline-start and margin-inline-end. These properties flip automatically with dir:
// components/Card.tsx
export function Card({ children }: { children: React.ReactNode }) {
return (
<div
style={{
paddingInlineStart: "1.5rem",
paddingInlineEnd: "1.5rem",
borderInlineStart: "4px solid var(--accent)",
}}
>
{children}
</div>
);
}
Tailwind has RTL variant support via the rtl: prefix, but it requires explicit opt-in on every utility. CSS logical properties in a design system component layer are cleaner: you write the rule once and it works in both directions.
Icons and illustrations that carry directionality (arrows, progress indicators, checkmarks pointing in a direction) need mirroring. The CSS property transform: scaleX(-1) on an SVG wrapper with a conditional is blunt but works. A cleaner approach is an Icon component that accepts a mirror prop and applies the transform based on the current locale’s direction.
Typography is the other RTL concern. Arabic and Hebrew fonts need to be loaded separately. Do not assume your default Latin font stack has coverage for Arabic glyphs. Missing glyphs will fall back to system fonts, causing mixed rendering. Configure font loading per locale:
// app/[locale]/layout.tsx
import { Cairo } from "next/font/google";
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
const cairo = Cairo({ subsets: ["arabic"] });
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = params;
const font = locale === "ar" ? cairo : inter;
const dir = locale === "ar" ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir} className={font.className}>
<body>{children}</body>
</html>
);
}
SEO: hreflang and Canonical URLs
Without proper hreflang annotations, search engines will index all locale variants as separate competing pages or, worse, pick one arbitrarily and ignore the others. The hreflang attribute tells crawlers which page to serve for which language and region combination.
Generate hreflang metadata at the layout level so it applies to every route under [locale]:
// app/[locale]/layout.tsx
import type { Metadata } from "next";
const SUPPORTED_LOCALES = ["en", "es", "fr", "ar"] as const;
const BASE_URL = "https://example.com";
export async function generateMetadata({
params,
}: {
params: { locale: string };
}): Promise<Metadata> {
const { locale } = params;
const alternates: Record<string, string> = {};
for (const l of SUPPORTED_LOCALES) {
alternates[l] = `${BASE_URL}/${l}`;
}
return {
alternates: {
canonical: `${BASE_URL}/${locale}`,
languages: alternates,
},
};
}
For pages with dynamic slugs (blog posts, product pages), the canonical and alternates need to include the full path:
// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from "next";
const SUPPORTED_LOCALES = ["en", "es", "fr", "ar"] as const;
const BASE_URL = "https://example.com";
type Props = {
params: { locale: string; slug: string };
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = params;
const languages: Record<string, string> = {};
for (const l of SUPPORTED_LOCALES) {
languages[l] = `${BASE_URL}/${l}/blog/${slug}`;
}
return {
alternates: {
canonical: `${BASE_URL}/${locale}/blog/${slug}`,
languages,
},
};
}
One production detail: if a blog post exists in English but not yet translated into French, do not include fr in the hreflang alternates for that post. Pointing hreflang at a non-existent or machine-translated page that returns the English content is misleading to both users and crawlers. Only list locales where the content genuinely exists.
The x-default hreflang tag tells crawlers which page to use when no other locale matches. Point it at the default locale:
languages["x-default"] = `${BASE_URL}/en/blog/${slug}`;
Tradeoffs
| Dimension | Subpath routing | Subdomain routing |
|---|---|---|
| Deployment complexity | Low | High (DNS, SSL, CORS) |
| Cookie sharing | Native | Requires cross-domain config |
| CDN geo-routing | Limited | Per-subdomain origin routing |
| SEO association | Strong (same domain) | Weaker (separate domain signals) |
| Canonical clarity | Straightforward | Requires careful setup |
| Auth state | Shared | Isolated per subdomain |
| Dimension | Lazy namespace loading | Single-file per locale |
|---|---|---|
| Initial bundle size | Smaller (only needed namespaces) | Larger (all strings) |
| Network requests | One per namespace per route | One per locale |
| Key organization | Grouped by feature | Flat, hard to navigate at scale |
| Build-time verification | Requires per-namespace type gen | Simpler, one schema |
| Translation coverage gaps | Easier to miss | Easier to audit |
Production Considerations
Missing translation fallback. When a key is missing in the current locale, next-intl defaults to the key name. In production, you want to fall back to the default locale string, not expose the key path to users. Configure onError and a fallback locale in your next-intl setup.
Translation file versioning. Deploying new translation files with new string keys before the code that uses them (or after, in rollbacks) causes key-not-found states. If you deploy translations and code atomically, this is not a problem. If your translation pipeline is separate from your deploy pipeline, you need fallback handling that tolerates missing keys gracefully.
Locale cookie persistence. When users switch locales, set the NEXT_LOCALE cookie with a long maxAge and path=/. Otherwise the preference only persists for the session.
// app/api/set-locale/route.ts
import { NextRequest, NextResponse } from "next/server";
const SUPPORTED_LOCALES = ["en", "es", "fr", "ar"];
export async function POST(request: NextRequest) {
const { locale } = await request.json();
if (!SUPPORTED_LOCALES.includes(locale)) {
return NextResponse.json({ error: "Unsupported locale" }, { status: 400 });
}
const response = NextResponse.json({ locale });
response.cookies.set("NEXT_LOCALE", locale, {
maxAge: 60 * 60 * 24 * 365, // 1 year
path: "/",
sameSite: "lax",
});
return response;
}
Type-safe translation keys. Generate TypeScript types from your translation files to catch missing or renamed keys at build time. next-intl supports this via its type augmentation API: declare a module with your message shape and the useTranslations hook will type-check key paths.
Sitemap generation. Your sitemap needs entries for every locale variant of every URL. In Next.js App Router, app/sitemap.ts returns an array of MetadataRoute.Sitemap entries. Generate one entry per locale per URL, with alternates populated. Search engines use the sitemap alternates alongside the hreflang tags in HTML to build the full locale graph.
// app/sitemap.ts
import { MetadataRoute } from "next";
const SUPPORTED_LOCALES = ["en", "es", "fr", "ar"] as const;
const BASE_URL = "https://example.com";
const STATIC_ROUTES = ["/", "/pricing", "/about"];
export default function sitemap(): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = [];
for (const route of STATIC_ROUTES) {
for (const locale of SUPPORTED_LOCALES) {
const url = `${BASE_URL}/${locale}${route === "/" ? "" : route}`;
const languages: Record<string, string> = {};
for (const l of SUPPORTED_LOCALES) {
languages[l] = `${BASE_URL}/${l}${route === "/" ? "" : route}`;
}
entries.push({
url,
lastModified: new Date(),
alternates: { languages },
});
}
}
return entries;
}
The Architecture Holds When Translation Volume Scales
The patterns here hold past the point where most i18n implementations start showing cracks. Namespace-based lazy loading means adding a new locale does not inflate the payload for all routes. Middleware-owned locale detection means the URL structure is consistent regardless of where users enter the app. Type-safe translation keys catch regressions before they reach production. And proper hreflang implementation means each locale variant earns its own search indexing rather than competing with or cannibalizing the default locale.
The piece that requires ongoing discipline is the translation pipeline itself: keeping namespaces in sync across locales, auditing coverage gaps before deploys, and deciding which routes genuinely need translated content vs. which ones can serve the default locale with a language switcher. That is an organizational problem, not a framework problem, and no amount of middleware abstraction solves it.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.