Building a multilingual Next.js application with the App Router requires a fundamentally different approach than the old Pages Router. The built-in i18n config is gone, replaced by a more flexible system built on middleware, dynamic route segments, and React Server Components. In this guide, you'll set up a production-ready internationalization system from scratch using next-intl — the most popular and lightweight i18n library purpose-built for the Next.js App Router.
By the end, you'll have locale-based routing, type-safe translations in both Server and Client Components, static rendering optimization, localized metadata for SEO, hreflang alternate links, and a multilingual sitemap. Basically, everything a real-world multilingual app actually needs.
Why next-intl for the App Router
When Next.js 13 introduced the App Router, it quietly removed the built-in i18n routing config that the Pages Router provided. That left developers scrambling for alternatives. Several libraries emerged, but next-intl has become the clear favorite for App Router projects — and honestly, it's not particularly close.
Here's why next-intl stands out:
- Tiny bundle size — approximately 2KB compared to 8KB for
react-i18nextor 12KB forreact-intl - Native Server Component support — translations rendered in Server Components add zero bytes to the client bundle
- Built-in routing — middleware for locale detection, redirects, and alternate link headers
- Type safety — full TypeScript support with autocompletion for translation keys
- ICU message syntax — built-in plurals, numbers, dates, and rich text formatting
- Next.js 16 compatible — actively maintained with dedicated Next.js 16 support
Project Setup and Installation
Start with an existing Next.js 16 App Router project, or create a new one. Then install next-intl:
npm install next-intl
Your project will follow this structure once setup is complete (it looks like a lot at first, but each piece has a clear purpose — you'll see):
├── messages/
│ ├── en.json
│ ├── es.json
│ └── fr.json
├── src/
│ ├── i18n/
│ │ ├── config.ts
│ │ ├── request.ts
│ │ └── routing.ts
│ ├── middleware.ts
│ └── app/
│ └── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ └── about/
│ └── page.tsx
└── next.config.ts
Step 1: Define Your Locale Configuration
Create a central configuration file that defines your supported locales. This file is shared across middleware, routing, and components — so getting it right here saves you headaches later:
// src/i18n/config.ts
export const locales = ['en', 'es', 'fr'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
Step 2: Configure Routing
Define your routing configuration using next-intl's defineRouting function. This is used by both the middleware and navigation utilities:
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';
import { locales, defaultLocale } from './config';
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: 'always'
});
// Create type-safe navigation helpers
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);
The localePrefix option controls how locale segments appear in your URLs. Your options are:
'always'— every URL includes the locale prefix (/en/about,/es/about)'as-needed'— the default locale is omitted (/aboutfor English,/es/aboutfor Spanish)'never'— no locale prefix in URLs (locale detected via cookies or headers only)
Step 3: Set Up Middleware
The middleware handles locale detection from the browser's Accept-Language header, redirects users to their preferred locale, and injects hreflang alternate link headers for SEO:
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
// Match all pathnames except for
// - API routes
// - _next (Next.js internals)
// - static files (images, fonts, etc.)
matcher: ['/', '/(en|es|fr)/:path*']
};
When a user hits your site, the middleware checks for a locale cookie first, then falls back to the Accept-Language header, and finally uses your default locale. It also automatically generates Link response headers with hreflang alternate URLs that search engines use to discover your translations. That last part is easy to overlook, but it matters a lot for multilingual SEO.
Step 4: Configure the next-intl Plugin
Wrap your Next.js configuration with the next-intl plugin. This connects the request configuration to the build process:
// next.config.ts
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig = {
// your existing Next.js config
};
export default withNextIntl(nextConfig);
Step 5: Create the Request Configuration
The request configuration runs on every server request and determines which messages to load for the current locale:
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
// Validate the locale
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
};
});
Step 6: Create Translation Files
Add your translation messages as JSON files. A nested structure organized by page or feature keeps things manageable as the project grows:
// messages/en.json
{
"HomePage": {
"title": "Welcome to our platform",
"description": "Build amazing applications with Next.js",
"cta": "Get Started"
},
"AboutPage": {
"title": "About Us",
"mission": "Our mission is to simplify web development"
},
"Navigation": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"Common": {
"learnMore": "Learn More",
"loading": "Loading..."
}
}
// messages/es.json
{
"HomePage": {
"title": "Bienvenido a nuestra plataforma",
"description": "Construye aplicaciones increíbles con Next.js",
"cta": "Comenzar"
},
"AboutPage": {
"title": "Sobre Nosotros",
"mission": "Nuestra misión es simplificar el desarrollo web"
},
"Navigation": {
"home": "Inicio",
"about": "Acerca de",
"contact": "Contacto"
},
"Common": {
"learnMore": "Más información",
"loading": "Cargando..."
}
}
Step 7: Set Up the Locale Layout
The [locale] layout is really the heart of the whole setup. It validates the locale, enables static rendering, loads messages, and wraps children with the client 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';
import type { ReactNode } from 'react';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
type Props = {
children: ReactNode;
params: Promise<{ locale: string }>;
};
export default async function LocaleLayout({
children,
params
}: Props) {
const { locale } = await params;
// Validate the locale
if (!routing.locales.includes(locale as any)) {
notFound();
}
// Enable static rendering
setRequestLocale(locale);
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
The setRequestLocale call is critical — don't skip it. Without it, next-intl opts into dynamic rendering whenever you use translation functions in Server Components. Calling it before any next-intl functions tells the framework to statically render the page at build time for each locale returned by generateStaticParams. This is one of those things that silently breaks your performance if you miss it.
Step 8: Use Translations in Server Components
In Server Components, use the awaitable getTranslations function from next-intl/server:
// src/app/[locale]/page.tsx
import { getTranslations, setRequestLocale } from 'next-intl/server';
type Props = {
params: Promise<{ locale: string }>;
};
export default async function HomePage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('HomePage');
return (
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
<button>{t('cta')}</button>
</main>
);
}
Remember to call setRequestLocale in every page and layout where you want static rendering. It must be called before any next-intl functions. Yes, every page. It's a little repetitive, but there's no way around it currently.
Step 9: Use Translations in Client Components
Client Components use the synchronous useTranslations hook. The translations are available because you wrapped the layout with NextIntlClientProvider:
// src/components/LanguageSwitcher.tsx
'use client';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from '@/i18n/routing';
import { routing } from '@/i18n/routing';
const localeNames: Record<string, string> = {
en: 'English',
es: 'Español',
fr: 'Français'
};
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
function handleChange(newLocale: string) {
router.replace(pathname, { locale: newLocale });
}
return (
<div>
{routing.locales.map((loc) => (
<button
key={loc}
onClick={() => handleChange(loc)}
disabled={loc === locale}
aria-current={loc === locale ? 'true' : undefined}
>
{localeNames[loc]}
</button>
))}
</div>
);
}
Use the Link, useRouter, usePathname, and redirect utilities exported from your routing configuration rather than the ones from next/navigation. These locale-aware versions automatically handle locale prefixes in URLs. Reaching for next/navigation out of habit is one of the most common mistakes people make when first setting this up.
Localized Metadata for SEO
Proper metadata is essential for multilingual SEO. Use the getTranslations function in your generateMetadata export to produce locale-specific titles and descriptions:
// src/app/[locale]/page.tsx
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Metadata } from 'next';
type Props = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({
params
}: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({
locale,
namespace: 'HomePage'
});
return {
title: t('title'),
description: t('description'),
alternates: {
canonical: `https://example.com/${locale}`,
languages: {
en: 'https://example.com/en',
es: 'https://example.com/es',
fr: 'https://example.com/fr'
}
}
};
}
The alternates.languages field generates <link rel="alternate" hreflang="..."> tags in the HTML head, telling search engines where to find each language variant of the page. Always include an x-default entry if you want to specify a fallback for unmatched languages — Google in particular pays attention to this.
Multilingual Sitemap
Next.js App Router supports generating sitemaps with the sitemap.ts convention. For a multilingual site, you need to include all locale variants with their alternate links:
// src/app/sitemap.ts
import type { MetadataRoute } from 'next';
const baseUrl = 'https://example.com';
const locales = ['en', 'es', 'fr'];
// All pages that need to appear in the sitemap
const pages = ['', '/about', '/contact'];
export default function sitemap(): MetadataRoute.Sitemap {
return pages.flatMap((page) =>
locales.map((locale) => ({
url: `${baseUrl}/${locale}${page}`,
lastModified: new Date(),
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${baseUrl}/${l}${page}`])
)
}
}))
);
}
This produces a sitemap where each URL entry includes xhtml:link alternates for every language — which is exactly what search engines expect for multilingual sites. It's a small thing to set up and makes a real difference in how your translated pages get indexed.
ICU Message Syntax: Plurals, Numbers, and Dates
next-intl uses ICU message syntax for complex translations. This handles plurals, number formatting, and date formatting that vary by locale — and if you've ever tried to handle pluralization manually across languages, you'll appreciate how much this saves you:
// messages/en.json
{
"Blog": {
"postCount": "You have {count, plural, =0 {no posts} one {1 post} other {# posts}}",
"publishedAt": "Published on {date, date, long}",
"price": "Price: {amount, number, ::currency/USD}"
}
}
// messages/es.json
{
"Blog": {
"postCount": "Tienes {count, plural, =0 {ningún artículo} one {1 artículo} other {# artículos}}",
"publishedAt": "Publicado el {date, date, long}",
"price": "Precio: {amount, number, ::currency/USD}"
}
}
Use them in your components by passing values to the translation function:
const t = await getTranslations('Blog');
// "You have 5 posts" (English) or "Tienes 5 artículos" (Spanish)
t('postCount', { count: 5 });
// "Published on February 27, 2026" (English) or "Publicado el 27 de febrero de 2026" (Spanish)
t('publishedAt', { date: new Date() });
// "Price: $29.99" (auto-formatted per locale)
t('price', { amount: 29.99 });
TypeScript Type Safety for Translation Keys
One of next-intl's strongest features is TypeScript autocompletion for your translation keys. Create a global type declaration file that tells TypeScript about your message structure:
// global.d.ts
import en from './messages/en.json';
type Messages = typeof en;
declare module 'next-intl' {
interface AppConfig {
Messages: Messages;
}
}
With this in place, calling t('nonExistentKey') produces a TypeScript error, and your IDE provides autocompletion for all valid translation keys. This catches missing or mistyped keys at compile time rather than at runtime. It's genuinely one of the nicest developer experience touches in the whole library.
Message Splitting for Large Applications
As your app grows, loading all translations for every page becomes wasteful. Split your messages by feature or page and load only what you need:
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
// Load common messages shared across all pages
const common = (await import(`../../messages/${locale}/common.json`)).default;
return {
locale,
messages: common
};
});
Then load page-specific messages in individual layouts or pages:
// src/app/[locale]/dashboard/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
export default async function DashboardLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
// Load dashboard-specific messages
const dashboardMessages = (
await import(`../../../../messages/${locale}/dashboard.json`)
).default;
return (
<NextIntlClientProvider messages={dashboardMessages}>
{children}
</NextIntlClientProvider>
);
}
NextIntlClientProvider instances can be nested. Inner providers merge their messages with outer ones, so child components still have access to common translations while also receiving page-specific ones. This composability is really well thought out.
RTL Language Support
If your application supports right-to-left languages like Arabic or Hebrew, you need to set the dir attribute on your HTML element based on the current locale:
// src/i18n/config.ts
export const locales = ['en', 'es', 'ar'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
const rtlLocales = new Set(['ar', 'he', 'fa']);
export function getDirection(locale: string): 'ltr' | 'rtl' {
return rtlLocales.has(locale) ? 'rtl' : 'ltr';
}
// src/app/[locale]/layout.tsx
import { getDirection } from '@/i18n/config';
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
const dir = getDirection(locale);
return (
<html lang={locale} dir={dir}>
<body>{/* ... */}</body>
</html>
);
}
Pair this with CSS logical properties (margin-inline-start instead of margin-left, padding-inline-end instead of padding-right) so your styles adapt automatically to both text directions without separate stylesheets. It's a bit of upfront work, but far easier than maintaining duplicate CSS.
Combining i18n with Next.js Middleware
Real applications often need middleware for more than just i18n — authentication checks, rate limiting, or A/B testing. You can compose next-intl's middleware with your own custom logic:
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { NextRequest } from 'next/server';
const intlMiddleware = createMiddleware(routing);
export default function middleware(request: NextRequest) {
// Custom logic before i18n
const isProtectedRoute = request.nextUrl.pathname.includes('/dashboard');
if (isProtectedRoute) {
const token = request.cookies.get('auth-token');
if (!token) {
return Response.redirect(new URL('/login', request.url));
}
}
// Delegate to next-intl middleware
return intlMiddleware(request);
}
export const config = {
matcher: ['/', '/(en|es|fr)/:path*']
};
Common Pitfalls and Solutions
Working with i18n in the App Router has some genuinely non-obvious gotchas. Here are the ones that trip people up most often:
Missing setRequestLocale calls
If you forget to call setRequestLocale in a page or layout, that route falls back to dynamic rendering. This isn't always obvious — your app works fine in development, but build times increase and you lose the performance benefits of static generation. Add setRequestLocale to every page and layout that uses translations.
Using next/navigation instead of next-intl/navigation
The navigation helpers from next/navigation (Link, useRouter, usePathname) are not locale-aware. If you use them, links will break or silently lose the locale prefix. Always import these from your routing configuration file where you called createNavigation.
Hydration mismatches with date or number formatting
If you format dates or numbers in Client Components, the server and client may produce different output due to timezone or locale differences. Use the formatting functions from next-intl (useFormatter) rather than native Intl APIs — next-intl ensures consistency between server and client rendering, which saves you from some very confusing hydration errors.
FAQ
Does the Next.js App Router have built-in i18n support?
No. The App Router removed the built-in i18n configuration that the Pages Router provided. You need an external library like next-intl, react-i18next, or paraglide-next to handle locale routing and translations. The official Next.js documentation recommends using middleware and dynamic route segments ([locale]) combined with one of these libraries.
What is the best i18n library for Next.js App Router in 2026?
next-intl is the most widely adopted choice in 2026 due to its small bundle size (approximately 2KB), native Server Component support, built-in routing with middleware, and full TypeScript type safety. It has confirmed compatibility with Next.js 16 and is actively maintained with features like ahead-of-time message compilation and SWC plugin integration.
How do I enable static rendering with i18n in the App Router?
Call setRequestLocale(locale) in every page and layout before using any next-intl functions, and export a generateStaticParams function that returns all your supported locales. This tells Next.js to statically generate each locale variant at build time instead of rendering dynamically on every request.
How do hreflang tags work with next-intl?
The next-intl middleware automatically generates Link response headers with hreflang alternate URLs for each supported locale, including an x-default entry. Search engines use these headers to discover your translated pages. You can also add <link rel="alternate"> tags in your layout's metadata using the alternates.languages field in generateMetadata.
Can I use next-intl without locale prefixes in the URL?
Yes. Set localePrefix: 'never' in your routing configuration. In this mode, the locale is detected from cookies or the Accept-Language header rather than the URL path. This approach works but isn't recommended for SEO, because search engines can't distinguish between language variants without unique URLs.