IntlPull
Tutorial
20 min read

Next.js 15 i18n: Guía completa de internacionalización 2026

Domina la internacionalización de Next.js 15 con esta completa guía. App Router, componentes de servidor, next-intl, enrutamiento y mejores prácticas de producción.

IntlPull Team
IntlPull Team
03 Feb 2026, 11:44 AM [PST]
On this page
Summary

Domina la internacionalización de Next.js 15 con esta completa guía. App Router, componentes de servidor, next-intl, enrutamiento y mejores prácticas de producción.

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 GetPages RouterApp Router
Server-rendered translationsSort ofYes, properly
StreamingHackyBuilt-in
Nested layouts per localeManual workJust works
Translation bundle sizeAll languagesCurrent 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-intlreact-i18nextnext-translate
Server Component supportNativeNeeds wrapperNative
Learning curveLowMediumLow
PluralizationICU formatICU with pluginBasic 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.

JSON
1{
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:

TypeScript
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:

TypeScript
1import { 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.

TypeScript
1import 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:

TypeScript
1import { 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:

TypeScript
1import { 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:

TypeScript
1'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:

JSON
1{
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:

TypeScript
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.

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:

TypeScript
1// 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:

TypeScript
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:

TypeScript
1export 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

TypeScript
1'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:

TypeScript
1export 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:

Terminal
1# 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:

TypeScript
const 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:

TypeScript
1const 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:

TypeScript
1render(
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:

  1. Set up next-intl with the folder structure above
  2. Get your routing working with the middleware
  3. Add translations incrementally as you build features
  4. 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.

Tags
nextjs
nextjs-15
i18n
next-intl
app-router
react
2026
2024
IntlPull Team
IntlPull Team
Engineering

Building tools to help teams ship products globally. Follow us for more insights on localization and i18n.