Next.js 15 i18n: Complete Internationalization Guide 2025
Master Next.js 15 internationalization with this comprehensive guide. App Router, Server Components, next-intl, routing, and production best practices.
I spent the better part of last month migrating a client's e-commerce site from Next.js 13 Pages Router to Next.js 15 App Router. The site needed to support 12 languages across Europe and Latin America. What I learned along the way is that i18n in Next.js 15 is genuinely better than before, but there are still plenty of ways to shoot yourself in the foot.
This is the guide I wish existed when I started.
What Actually Changed in Next.js 15
Let me be honest: if you're coming from the Pages Router, the App Router feels like a different framework. The good news? Server Components change everything about how we handle translations.
Here's what clicked for me:
With the old approach, every translation key had to ship to the client. Your Japanese users were downloading Spanish, German, and French strings they'd never see. With Server Components, translations can stay on the server entirely. The client only gets the rendered HTML.
| What You Get | Pages Router | App Router |
|---|---|---|
| Server-rendered translations | Sort of | Yes, properly |
| Streaming | Hacky | Built-in |
| Nested layouts per locale | Manual work | Just works |
| Translation bundle size | All languages | Current language only |
| Server-rendered translations | Sort of | Yes, properly |
|---|---|---|
| Streaming | Hacky | Built-in |
| Nested layouts per locale | Manual work | Just works |
| Translation bundle size | All languages | Current language only |
| Server-rendered translations | Sort of | Yes, properly |
|---|---|---|
| Streaming | Hacky | Built-in |
| Nested layouts per locale | Manual work | Just works |
| Translation bundle size | All languages | Current language only |
| Streaming | Hacky | Built-in |
|---|---|---|
| Nested layouts per locale | Manual work | Just works |
| Translation bundle size | All languages | Current language only |
| Nested layouts per locale | Manual work | Just works |
|---|---|---|
| Translation bundle size | All languages | Current language only |
| Translation bundle size | All languages | Current language only |
|---|
The streaming part surprised me. I had a page with 200+ translation keys, and instead of waiting for everything, the shell renders immediately while translations stream in.
Picking a Library (I Have Opinions)
I've used all three major options in production. Here's my honest take:
next-intl is what I reach for now. It was designed specifically for App Router from the ground up. The developer experience is excellent, TypeScript support is first-class, and the bundle is tiny. The maintainer, Jan Amann, is incredibly responsive to issues.
react-i18next is the industry standard for a reason. If your team already knows it, there's value in that. But getting it to work properly with Server Components requires extra configuration that always feels like fighting the framework.
next-translate is the lightest option. I'd consider it for a marketing site with minimal interactivity. For anything with forms, dynamic content, or complex pluralization, you'll hit limitations quickly.
| next-intl | react-i18next | next-translate | |
|---|---|---|---|
| Server Component support | Native | Needs wrapper | Native |
| Learning curve | Low | Medium | Low |
| Pluralization | ICU format | ICU with plugin | Basic only |
| Bundle size | ~2KB | ~8KB | ~1.5KB |
| Server Component support | Native | Needs wrapper | Native |
|---|---|---|---|
| Learning curve | Low | Medium | Low |
| Pluralization | ICU format | ICU with plugin | Basic only |
| Bundle size | ~2KB | ~8KB | ~1.5KB |
| Server Component support | Native | Needs wrapper | Native |
|---|---|---|---|
| Learning curve | Low | Medium | Low |
| Pluralization | ICU format | ICU with plugin | Basic only |
| Bundle size | ~2KB | ~8KB | ~1.5KB |
| Learning curve | Low | Medium | Low |
|---|---|---|---|
| Pluralization | ICU format | ICU with plugin | Basic only |
| Bundle size | ~2KB | ~8KB | ~1.5KB |
| Pluralization | ICU format | ICU with plugin | Basic only |
|---|---|---|---|
| Bundle size | ~2KB | ~8KB | ~1.5KB |
| Bundle size | ~2KB | ~8KB | ~1.5KB |
|---|
For this guide, I'm using next-intl. If you're starting fresh, I'd recommend the same.
Setting Things Up (The Right Way)
Installation
npm install next-intl
Folder Structure
Here's where I messed up initially. I tried to be clever with the folder structure and it backfired. Stick with this:
/messages
/en.json
/es.json
/de.json
/app
/[locale]
/layout.tsx
/page.tsx
/(auth)
/login/page.tsx
/i18n
/config.ts
/request.ts
/middleware.tsTranslation Files
Keep your JSON files organized by feature, not by page. I learned this the hard way when we had 40 pages and the files became unmanageable.
{
"common": {
"buttons": {
"submit": "Submit",
"cancel": "Cancel",
"save": "Save changes"
},
"errors": {
"required": "This field is required",
"network": "Something went wrong. Please try again."
}
},
"checkout": {
"title": "Complete your order",
"shipping": "Shipping address",
"payment": "Payment method"
}
}The Config Files
Create i18n/config.ts:
export const locales = ['en', 'es', 'de', 'fr', 'ja'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';And i18n/request.ts:
import { getRequestConfig } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from './config';
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as any)) notFound();
return {
messages: (await import(`../messages/${locale}.json`)).default
};
});Middleware
This is where locale detection happens. The middleware runs on every request and figures out which language to show.
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n/config';
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'always'
});
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)']
};One thing that tripped me up: that matcher regex. If you have a /public folder with images, make sure your matcher doesn't try to localize those paths. The .*\\. part handles files with extensions, but double-check if you have extensionless assets.
Layout Setup
Your root layout at app/[locale]/layout.tsx:
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from '@/i18n/config';
export default async function LocaleLayout({
children,
params: { locale }
}: {
children: React.ReactNode;
params: { locale: string };
}) {
if (!locales.includes(locale as any)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}Actually Using Translations
Server Components
This is the cleanest part. No hooks, no context, just async/await:
import { getTranslations } from 'next-intl/server';
export default async function ProductPage() {
const t = await getTranslations('product');
return (
<div>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</div>
);
}Client Components
When you need interactivity:
'use client';
import { useTranslations } from 'next-intl';
export function AddToCartButton() {
const t = useTranslations('product');
return (
<button onClick={handleAddToCart}>
{t('addToCart')}
</button>
);
}Variables and Pluralization
This is where ICU format shines. In your JSON:
{
"cart": {
"items": "{count, plural, =0 {Your cart is empty} one {# item in cart} other {# items in cart}}",
"total": "Total: {price, number, ::currency/USD}"
}
}And in your component:
t('cart.items', { count: 3 }) // "3 items in cart"
t('cart.total', { price: 99.99 }) // "Total: $99.99"The ICU plural syntax looks intimidating at first, but it handles edge cases you'd never think of. Russian has different plural forms for numbers ending in 1, 2-4, and 5-20. ICU handles this automatically.
Things I Wish Someone Told Me Earlier
The Hydration Mismatch Problem
If you see hydration errors with dates or numbers, it's because the server rendered with one locale but the client initialized with another. The fix:
// In layout.tsx
<NextIntlClientProvider
messages={messages}
timeZone="Europe/Berlin" // Be explicit
now={new Date()} // Pass server time
>Don't Forget generateStaticParams
Without this, your locale pages won't be statically generated:
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}I missed this on one deploy and wondered why my site was slow. Every page was server-rendering on demand.
The SEO Stuff That Actually Matters
Hreflang tags are crucial. Google needs to know about your language versions:
export async function generateMetadata({ params: { locale } }) {
const t = await getTranslations('meta');
return {
title: t('title'),
alternates: {
canonical: `https://example.com/${locale}`,
languages: {
'en': 'https://example.com/en',
'es': 'https://example.com/es',
'de': 'https://example.com/de',
'x-default': 'https://example.com/en'
}
}
};
}That x-default is easy to forget but important for users whose language you don't support.
Building a Language Switcher
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from 'next-intl/client';
export function LanguageSwitcher() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const switchLocale = (newLocale: string) => {
router.replace(pathname, { locale: newLocale });
};
return (
<select value={locale} onChange={(e) => switchLocale(e.target.value)}>
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="de">Deutsch</option>
</select>
);
}One gotcha: usePathname from next-intl/client returns the path without the locale prefix. The one from next/navigation includes it. I've mixed these up more times than I'd like to admit.
URL Strategies
You have three options:
Always show locale prefix (/en/about, /es/about): Simplest to implement, clearest for users and search engines. This is what I recommend for most sites.
Hide default locale (/about for English, /es/about for Spanish): Looks cleaner but adds complexity. You need to handle redirects carefully.
Domain-based (example.com, example.es): Best for SEO in different countries, but requires more infrastructure.
For the domain approach, you'd configure next-intl like this:
export default createMiddleware({
locales,
defaultLocale,
domains: [
{ domain: 'example.com', defaultLocale: 'en' },
{ domain: 'example.es', defaultLocale: 'es' },
{ domain: 'example.de', defaultLocale: 'de' }
]
});When JSON Files Become a Pain
Around 500 translation keys, managing JSON files manually starts to hurt. Some problems I've run into:
This is where a translation management system helps. I've been using IntlPull for my recent projects. The workflow looks like:
# Push your source strings
intlpull push
# Get AI translations (or send to human translators)
intlpull translate --languages es,de,fr
# Pull everything back
intlpull pullThe intlpull watch command is handy during development. It syncs changes as you code.
For apps that need to update translations without redeploying (think: fixing a typo in production), IntlPull has an OTA feature that fetches translations at runtime. But honestly, for most projects, redeploying is fine and simpler.
Performance Notes
Server Components already give you a huge win by keeping translations off the client bundle. Beyond that:
Split by namespace. Don't load all 2000 keys when a page only needs 50. In your getMessages call:
const messages = await getMessages({ namespace: 'checkout' });Use Suspense for heavy pages. If you have a page with lots of translated content:
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails />
</Suspense>Don't over-optimize. I've seen teams spend days shaving kilobytes off translation bundles when their images were megabytes. Focus on what matters.
Common Questions
Can I mix App Router and Pages Router during migration?
Yes, and I recommend it. Migrate page by page. next-intl can work in both, though the setup differs.
What about RTL languages like Arabic and Hebrew?
Set the dir attribute based on locale:
const rtlLocales = ['ar', 'he', 'fa'];
const dir = rtlLocales.includes(locale) ? 'rtl' : 'ltr';
return <html lang={locale} dir={dir}>You'll also need RTL-aware CSS. Libraries like Tailwind have RTL variants, or you can use logical properties (margin-inline-start instead of margin-left).
How do I handle translation keys in a monorepo?
Keep shared translations in a package and import them. For project-specific translations, I usually keep those in each app.
Testing translated components?
Wrap your component with the provider in tests:
render(
<NextIntlClientProvider locale="en" messages={messages}>
<MyComponent />
</NextIntlClientProvider>
);Wrapping Up
Next.js 15's App Router genuinely makes i18n better. Server Components were the missing piece that makes translations feel native rather than bolted on.
If you're just starting out:
The initial setup takes maybe an hour. After that, adding new languages is straightforward.
If you want to try IntlPull for managing translations, there's a free tier that should cover most side projects. But honestly, JSON files work fine until they don't. You'll know when you need something more.