Respuesta rápida
Para añadir internacionalización (i18n) a Next.js 15, utiliza next-intl con App Router. Instálalo con npm install next-intl, create a [locale] folder in your app directory, configure middleware for locale detection, and use getTranslations() in Server Components or useTranslations() in Client Components. Next.js 15's Server Components eliminate client-side translation bundle bloat by rendering translations on the server.
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 |
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 |
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.ts
Translation 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.
JSON1{ 2 "common": { 3 "buttons": { 4 "submit": "Submit", 5 "cancel": "Cancel", 6 "save": "Save changes" 7 }, 8 "errors": { 9 "required": "This field is required", 10 "network": "Something went wrong. Please try again." 11 } 12 }, 13 "checkout": { 14 "title": "Complete your order", 15 "shipping": "Shipping address", 16 "payment": "Payment method" 17 } 18}
The Config Files
Create i18n/config.ts:
TypeScriptexport const locales = ['en', 'es', 'de', 'fr', 'ja'] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = 'en';
And i18n/request.ts:
TypeScript1import { getRequestConfig } from 'next-intl/server'; 2import { notFound } from 'next/navigation'; 3import { locales } from './config'; 4 5export default getRequestConfig(async ({ locale }) => { 6 if (!locales.includes(locale as any)) notFound(); 7 8 return { 9 messages: (await import(`../messages/${locale}.json`)).default 10 }; 11});
Middleware
This is where locale detection happens. The middleware runs on every request and figures out which language to show.
TypeScript1import createMiddleware from 'next-intl/middleware'; 2import { locales, defaultLocale } from './i18n/config'; 3 4export default createMiddleware({ 5 locales, 6 defaultLocale, 7 localePrefix: 'always' 8}); 9 10export const config = { 11 matcher: ['/((?!api|_next|.*\\..*).*)'] 12};
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:
TypeScript1import { NextIntlClientProvider } from 'next-intl'; 2import { getMessages } from 'next-intl/server'; 3import { notFound } from 'next/navigation'; 4import { locales } from '@/i18n/config'; 5 6export default async function LocaleLayout({ 7 children, 8 params: { locale } 9}: { 10 children: React.ReactNode; 11 params: { locale: string }; 12}) { 13 if (!locales.includes(locale as any)) { 14 notFound(); 15 } 16 17 const messages = await getMessages(); 18 19 return ( 20 <html lang={locale}> 21 <body> 22 <NextIntlClientProvider messages={messages}> 23 {children} 24 </NextIntlClientProvider> 25 </body> 26 </html> 27 ); 28}
Actually Using Translations
Server Components
This is the cleanest part. No hooks, no context, just async/await:
TypeScript1import { getTranslations } from 'next-intl/server'; 2 3export default async function ProductPage() { 4 const t = await getTranslations('product'); 5 6 return ( 7 <div> 8 <h1>{t('title')}</h1> 9 <p>{t('description')}</p> 10 </div> 11 ); 12}
Client Components
When you need interactivity:
TypeScript1'use client'; 2 3import { useTranslations } from 'next-intl'; 4 5export function AddToCartButton() { 6 const t = useTranslations('product'); 7 8 return ( 9 <button onClick={handleAddToCart}> 10 {t('addToCart')} 11 </button> 12 ); 13}
Variables and Pluralization
This is where ICU format shines. In your JSON:
JSON1{ 2 "cart": { 3 "items": "{count, plural, =0 {Your cart is empty} one {# item in cart} other {# items in cart}}", 4 "total": "Total: {price, number, ::currency/USD}" 5 } 6}
And in your component:
TypeScriptt('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.
Consejo: Usa nuestro Editor de Mensajes ICU gratuito para construir y probar visualmente la sintaxis de pluralización ICU antes de añadirla a tus archivos de traducción.
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:
TypeScript1// In layout.tsx 2<NextIntlClientProvider 3 messages={messages} 4 timeZone="Europe/Berlin" // Be explicit 5 now={new Date()} // Pass server time 6>
Don't Forget generateStaticParams
Without this, your locale pages won't be statically generated:
TypeScriptexport 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:
TypeScript1export async function generateMetadata({ params: { locale } }) { 2 const t = await getTranslations('meta'); 3 4 return { 5 title: t('title'), 6 alternates: { 7 canonical: `https://example.com/${locale}`, 8 languages: { 9 'en': 'https://example.com/en', 10 'es': 'https://example.com/es', 11 'de': 'https://example.com/de', 12 'x-default': 'https://example.com/en' 13 } 14 } 15 }; 16}
That x-default is easy to forget but important for users whose language you don't support.
Building a Language Switcher
TypeScript1'use client'; 2 3import { useLocale } from 'next-intl'; 4import { usePathname, useRouter } from 'next-intl/client'; 5 6export function LanguageSwitcher() { 7 const locale = useLocale(); 8 const pathname = usePathname(); 9 const router = useRouter(); 10 11 const switchLocale = (newLocale: string) => { 12 router.replace(pathname, { locale: newLocale }); 13 }; 14 15 return ( 16 <select value={locale} onChange={(e) => switchLocale(e.target.value)}> 17 <option value="en">English</option> 18 <option value="es">Espanol</option> 19 <option value="de">Deutsch</option> 20 </select> 21 ); 22}
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:
TypeScript1export default createMiddleware({ 2 locales, 3 defaultLocale, 4 domains: [ 5 { domain: 'example.com', defaultLocale: 'en' }, 6 { domain: 'example.es', defaultLocale: 'es' }, 7 { domain: 'example.de', defaultLocale: 'de' } 8 ] 9});
When JSON Files Become a Pain
Around 500 translation keys, managing JSON files manually starts to hurt. Some problems I've run into:
- Translators accidentally breaking JSON syntax
- No way to see what's missing across languages
- Copy-pasting new keys to 12 files
- Forgetting to remove deleted keys from other languages
Solución rápida para problemas JSON: Nuestra herramienta gratuita Comparador de JSON te ayuda a comparar archivos de traducción entre idiomas y detectar instantáneamente claves faltantes.
This is where a translation management system helps. I've been using IntlPull for my recent projects—it's purpose-built for Next.js i18n workflows. The workflow looks like:
Terminal1# Push your source strings 2npx @intlpullhq/cli upload 3 4# Get AI translations (or send to human translators) 5npx @intlpullhq/cli translate --languages es,de,fr 6 7# Pull everything back 8npx @intlpullhq/cli download
The npx @intlpullhq/cli watch command is handy during development. It syncs changes as you code.
Por qué IntlPull para proyectos Next.js:
- Integración nativa con next-intl - Funciona con tu estructura de carpetas existente
- Traducciones con IA - Obtén traducciones instantáneas con conocimiento del contexto
- Flujo de trabajo compatible con Git - Descarga traducciones directamente a tu repositorio
- Actualizaciones OTA - Actualiza traducciones sin redesplegar
- Plan gratuito - Cubre la mayoría de proyectos personales y startups
For apps that need to update translations without redeploying, IntlPull's OTA feature fetches translations at runtime. But for most projects, the CLI workflow is 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:
TypeScriptconst messages = await getMessages({ namespace: 'checkout' });
Use Suspense for heavy pages. If you have a page with lots of translated content:
TypeScript<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:
TypeScript1const rtlLocales = ['ar', 'he', 'fa']; 2const dir = rtlLocales.includes(locale) ? 'rtl' : 'ltr'; 3 4return <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:
TypeScript1render( 2 <NextIntlClientProvider locale="en" messages={messages}> 3 <MyComponent /> 4 </NextIntlClientProvider> 5);
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:
- Set up next-intl with the folder structure above
- Get your routing working with the middleware
- Add translations incrementally as you build features
- When you outgrow JSON files, look at a TMS
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.
Frequently Asked Questions
What is the best i18n library for Next.js 15?
next-intl is the best i18n library for Next.js 15 because it's built specifically for App Router and Server Components. It offers native Server Component support without wrappers, a tiny 2KB bundle size, full TypeScript support, and ICU format for pluralization. react-i18next works but requires extra configuration for Server Components.
Does Next.js 15 have built-in i18n support?
Next.js 15 provides routing infrastructure for i18n (locale-based URLs via [locale] folders and middleware) but does not include translation functionality. You need a library like next-intl, react-i18next, or next-translate to handle actual translations. The App Router's [locale] dynamic segment is the foundation for i18n routing.
How do I add multiple languages to Next.js?
To add multiple languages to Next.js 15: (1) Create a [locale] folder in your app directory, (2) Add translation JSON files in a messages folder (en.json, es.json, etc.), (3) Configure middleware for locale detection and routing, (4) Use getTranslations() or useTranslations() hooks to display translated strings. IntlPull can automate translation management and provide AI translations for new languages.
How do Server Components improve i18n in Next.js?
Server Components eliminate client-side translation bundle bloat. In Pages Router, all translation files shipped to the client (users downloaded every language). With Server Components, translations render on the server and only HTML ships to clients. A page with 200+ translation keys now loads instantly because strings don't add to the JavaScript bundle.
What URL structure should I use for multilingual Next.js apps?
Use locale prefix in URLs (/en/about, /es/about) for most sites—it's clearest for users and best for SEO. Configure next-intl middleware with localePrefix: 'always'. For country-specific targeting, consider domain-based routing (example.com, example.es) which requires DNS and hosting configuration but provides strongest local SEO signals.
How do I handle missing translations in Next.js?
Configure a fallback language in your i18n setup to show default text when translations are missing. With next-intl, set fallbackLocale: 'en' in your config. For production apps, use IntlPull's missing translation detection to identify gaps before deployment and auto-fill with AI translation during development.
