Back to Blog
Tutorial
Featured

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.

IntlPull Team
IntlPull Team
Engineering
January 5, 202520 min read

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
Server-rendered translationsSort ofYes, properly
StreamingHackyBuilt-in
Nested layouts per localeManual workJust works
Translation bundle sizeAll languagesCurrent language only
Server-rendered translationsSort ofYes, properly
StreamingHackyBuilt-in
Nested layouts per localeManual workJust works
Translation bundle sizeAll languagesCurrent language only
StreamingHackyBuilt-in
Nested layouts per localeManual workJust works
Translation bundle sizeAll languagesCurrent language only
Nested layouts per localeManual workJust works
Translation bundle sizeAll languagesCurrent language only
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
Server Component supportNativeNeeds wrapperNative
Learning curveLowMediumLow
PluralizationICU formatICU with pluginBasic only
Bundle size~2KB~8KB~1.5KB
Server Component supportNativeNeeds wrapperNative
Learning curveLowMediumLow
PluralizationICU formatICU with pluginBasic only
Bundle size~2KB~8KB~1.5KB
Learning curveLowMediumLow
PluralizationICU formatICU with pluginBasic only
Bundle size~2KB~8KB~1.5KB
PluralizationICU formatICU with pluginBasic 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.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.

{
  "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:

  • 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
  • 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 pull

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

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

    nextjs
    nextjs-15
    i18n
    next-intl
    app-router
    react
    2025
    2024
    Share:

    Ready to simplify your i18n workflow?

    Start managing translations with IntlPull. Free tier included.