All articles🇬🇧 English
10 min

Next.js App Router i18n: Library Comparison and Choosing next-intl

Detailed comparison of i18n solutions for Next.js 15+: next-intl, next-i18next, next-translate, and custom solutions. Why next-intl is the best choice for App Router.

next.jsi18nlocalizationapp-routernext-intl

The Problem

You want to add multi-language support to your Next.js application with App Router. You open your search engine and see dozens of solutions:

  • next-intl
  • next-i18next
  • next-translate
  • "Build your own solution"
  • "Use Next.js built-in i18n"

Which one to choose? Why do some recommend next-i18next while others say it's outdated? Let's figure this out systematically.

The Real Task

Recently, I implemented localization in a project with the following requirements:

  • Next.js 15+ with App Router
  • Russian (primary) and English
  • URL structure: /ru/blog, /en/blog (always with locale prefix)
  • SEO: proper hreflang tags, sitemap with locales
  • Server Components for performance
  • Easy maintenance and extensibility

Sounds standard, but choosing an i18n solution turned out to be less obvious than expected.

Comparison Table

I created a table based on criteria important for modern Next.js projects:

Criterionnext-intlnext-i18nextnext-translateCustomWeight
App Router support✅ Excellent⚠️ Limited❌ Pages Only✅ Full control30%
Performance✅ Server Components⚠️ Client-side⚠️ Client-side✅ Optimized25%
Developer Experience✅ Excellent✅ Good⚠️ Average❌ Complex20%
Bundle size✅ Small (~5KB)⚠️ Medium (~15KB)✅ Small (~3KB)✅ Minimal15%
Features✅ Rich✅ Rich⚠️ Basic❌ DIY10%
TOTAL9.5/106.5/105/106/10100%

Criterion Breakdown

1. App Router Support (30% weight)

next-intl: ✅

  • Built specifically for App Router
  • Server Components support out of the box
  • Middleware for routing
  • Active development and updates

next-i18next: ⚠️

  • Originally designed for Pages Router
  • App Router support "through workarounds"
  • Documentation mostly for Pages
  • Developers themselves recommend next-intl for App Router

next-translate: ❌

  • Officially supports only Pages Router
  • App Router not in the roadmap

Custom: ✅

  • Full control, but...
  • Need to implement everything yourself
  • Many edge cases

2. Performance (25% weight)

next-intl: ✅

// Server Component - translations loaded on server
import { getTranslations } from 'next-intl/server'

export default async function BlogPage() {
  const t = await getTranslations('Blog')
  
  return <h1>{t('title')}</h1> // Rendered on server!
}

next-i18next: ⚠️

// Client Component - translations in bundle
'use client'
import { useTranslation } from 'next-i18next'

export default function BlogPage() {
  const { t } = useTranslation('blog')
  return <h1>{t('title')}</h1> // Client-side rendering
}

Result:

  • next-intl: translations don't go into JS bundle → smaller bundle, faster FCP
  • next-i18next: all translations in bundle → larger bundle, slower loading

3. Developer Experience (20% weight)

next-intl: ✅

// messages/en.json
{
  "Blog": {
    "title": "Blog",
    "readMore": "Read more"
  }
}

// components/BlogCard.tsx
import { useTranslations } from 'next-intl'

export default function BlogCard() {
  const t = useTranslations('Blog')
  
  return (
    <div>
      <h2>{t('title')}</h2>
      <button>{t('readMore')}</button>
    </div>
  )
}

TypeScript autocomplete:

// Generate types from JSON
type Messages = typeof import('./messages/en.json')

declare global {
  interface IntlMessages extends Messages {}
}

next-i18next: ✅ (similar, but without Server Components)

next-translate: ⚠️ (fewer features)

Custom: ❌ (need to write everything yourself)

4. Bundle Size (15% weight)

Real measurements for a typical project:

# next-intl
Client bundles:
  ├─ /en/blog         ~125 KB (gzip: ~35 KB)
  └─ i18n overhead:   ~5 KB

# next-i18next
Client bundles:
  ├─ /en/blog         ~150 KB (gzip: ~42 KB)
  └─ i18n overhead:   ~15 KB + all page translations

Conclusion: next-intl saves ~7-10 KB per page thanks to Server Components.

5. Features (10% weight)

What you need in real projects:

Featurenext-intlnext-i18nextnext-translateCustom
Basic translations🔨
Pluralization🔨
Date formatting⚠️🔨
Number formatting⚠️🔨
RTL support🔨
Localized URLs⚠️🔨
Metadata (SEO)⚠️🔨
Time zones⚠️🔨

🔨 = need to write yourself

Choice: next-intl

Why next-intl?

  1. Built for App Router — no hacks or workarounds needed
  2. Server Components — best performance out of the box
  3. Excellent DX — typing, autocomplete, clear documentation
  4. Active development — updates for every Next.js version
  5. Rich functionality — everything needed for enterprise projects

When NOT to choose next-intl?

  • Using Pages Router → next-i18next
  • Migrating from next-i18next and no time to rewrite → keep next-i18next
  • Very simple 2-page project → custom solution might suffice

Practical Implementation

Installation

npm install next-intl

Project Structure

app/
├── [locale]/
│   ├── layout.tsx          # Layout with NextIntlClientProvider
│   ├── page.tsx            # Home page
│   ├── blog/
│   │   ├── page.tsx
│   │   └── [slug]/page.tsx
│   └── about/
│       └── page.tsx
├── layout.tsx              # Root layout
└── page.tsx                # Redirect to /en

messages/
├── ru.json                 # Russian translations
└── en.json                 # English translations

i18n/
├── config.ts               # Locale configuration
├── request.ts              # Message loading
└── routing.ts              # Routing and navigation

middleware.ts               # Locale middleware

Configuration

1. i18n/config.ts

export const locales = ['ru', 'en'] as const
export type Locale = (typeof locales)[number]

export const defaultLocale: Locale = 'ru'

export const localeNames: Record<Locale, string> = {
  ru: 'Русский',
  en: 'English',
}

export const localeFlags: Record<Locale, string> = {
  ru: '🇷🇺',
  en: '🇬🇧',
}

2. i18n/routing.ts

import { defineRouting } from 'next-intl/routing'
import { locales, defaultLocale } from './config'

export const routing = defineRouting({
  locales,
  defaultLocale,
  localePrefix: 'always', // Always show locale in URL
})

export const { Link, redirect, usePathname, useRouter } =
  createNavigation(routing)

3. 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
  }

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  }
})

4. middleware.ts

import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)

export const config = {
  matcher: ['/', '/(ru|en)/:path*'],
}

Translation Files

messages/ru.json

{
  "Navigation": {
    "home": "Главная",
    "blog": "Блог",
    "about": "О нас"
  },
  "Blog": {
    "title": "Блог",
    "readMore": "Читать далее",
    "publishedAt": "Опубликовано {date}",
    "readingTime": "{minutes} мин. чтения"
  },
  "SEO": {
    "title": "DevBlog — Web Development Articles",
    "description": "Tutorials about Next.js, React, TypeScript and modern web development"
  }
}

messages/en.json

{
  "Navigation": {
    "home": "Home",
    "blog": "Blog",
    "about": "About"
  },
  "Blog": {
    "title": "Blog",
    "readMore": "Read more",
    "publishedAt": "Published on {date}",
    "readingTime": "{minutes} min read"
  },
  "SEO": {
    "title": "DevBlog — Web Development Articles",
    "description": "Tutorials about Next.js, React and TypeScript"
  }
}

Using in Components

Server Component

// app/[locale]/blog/page.tsx
import { getTranslations } from 'next-intl/server'

export async function generateMetadata({
  params: { locale },
}: {
  params: { locale: string }
}) {
  const t = await getTranslations({ locale, namespace: 'SEO' })

  return {
    title: t('title'),
    description: t('description'),
  }
}

export default async function BlogPage() {
  const t = await getTranslations('Blog')

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </div>
  )
}

Client Component

'use client'

import { useTranslations } from 'next-intl'

export default function BlogCard({ publishedAt }: { publishedAt: Date }) {
  const t = useTranslations('Blog')

  return (
    <div>
      <button>{t('readMore')}</button>
      <time>
        {t('publishedAt', { date: publishedAt.toLocaleDateString() })}
      </time>
    </div>
  )
}

Language Switcher

'use client'

import { useParams } from 'next/navigation'
import { usePathname, useRouter } from '@/i18n/routing'
import { locales, localeFlags, localeNames } from '@/i18n/config'

export default function LanguageSwitcher() {
  const params = useParams()
  const pathname = usePathname()
  const router = useRouter()

  const currentLocale = params.locale as string

  const switchLocale = (locale: string) => {
    router.replace(pathname, { locale })
  }

  return (
    <div className="flex gap-2">
      {locales.map((locale) => (
        <button
          key={locale}
          onClick={() => switchLocale(locale)}
          className={currentLocale === locale ? 'font-bold' : ''}
        >
          {localeFlags[locale]} {localeNames[locale]}
        </button>
      ))}
    </div>
  )
}

Advanced Features

Date and Number Formatting

import { useFormatter } from 'next-intl'

export default function StatsCard() {
  const format = useFormatter()
  const views = 1_234_567
  const date = new Date('2025-01-15')

  return (
    <div>
      <p>{format.number(views)}</p> {/* 1,234,567 or 1 234 567 */}
      <time>{format.dateTime(date, { dateStyle: 'long' })}</time>
    </div>
  )
}

SEO: Hreflang Tags

// app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({
  params: { locale, slug },
}: PageProps) {
  const t = await getTranslations({ locale, namespace: 'SEO' })

  return {
    title: t('blogPost.title'),
    description: t('blogPost.description'),
    alternates: {
      canonical: `/${locale}/blog/${slug}`,
      languages: {
        ru: `/ru/blog/${slug}`,
        en: `/en/blog/${slug}`,
      },
    },
  }
}

Result in HTML:

<link rel="canonical" href="https://example.com/en/blog/nextjs-i18n" />
<link rel="alternate" hreflang="ru" href="https://example.com/ru/blog/nextjs-i18n" />
<link rel="alternate" hreflang="en" href="https://example.com/en/blog/nextjs-i18n" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/blog/nextjs-i18n" />

Localized Sitemap

// app/sitemap.ts
import { getTranslations } from 'next-intl/server'
import { locales } from '@/i18n/config'

export default async function sitemap() {
  const posts = await getPosts()

  return posts.flatMap((post) =>
    locales.map((locale) => ({
      url: `https://example.com/${locale}/blog/${post.slug}`,
      lastModified: post.updatedAt,
    }))
  )
}

Type Safety

// types/i18n.d.ts
type Messages = typeof import('../messages/en.json')

declare global {
  interface IntlMessages extends Messages {}
}

Now TypeScript will check translation keys!

Implementation Results

After implementing next-intl in a real project:

Performance:

  • Bundle reduced by ~8 KB (gzip)
  • FCP improved by ~150ms (server-side translations)
  • Lighthouse Score: +3 points

DX:

  • TypeScript autocomplete works perfectly
  • Hot Reload when changing translations
  • Clear error messages

SEO:

  • Proper hreflang tags
  • Localized sitemap
  • Canonical URLs for each locale

Common Pitfalls

1. Server vs Client Components

Wrong:

// Server Component
import { useTranslations } from 'next-intl' // This is for client!

export default async function Page() {
  const t = useTranslations('Blog') // Error!
  return <h1>{t('title')}</h1>
}

Correct:

import { getTranslations } from 'next-intl/server'

export default async function Page() {
  const t = await getTranslations('Blog')
  return <h1>{t('title')}</h1>
}

2. Dynamic Parameters in Next.js 15

// Always await params in Next.js 15
export async function generateMetadata({ params }: PageProps) {
  const { locale } = await params
  const t = await getTranslations({ locale, namespace: 'SEO' })
  // ...
}

Conclusions

When to use next-intl:

  • Next.js 13+ with App Router
  • Need Server Components
  • Performance matters
  • Complex localization (formatting, pluralization, etc.)
  • Enterprise project

When to consider alternatives:

  • Pages Router → next-i18next
  • Very simple project → Custom solution
  • Already using next-i18next → might not be worth migrating

Key advantages of next-intl:

  1. Server Components — translations not in bundle
  2. Type safety — autocomplete in IDE
  3. Performance — less JS, faster FCP
  4. SEO — proper hreflang and alternates
  5. DX — clear API, great documentation

Summary

Choosing an i18n solution isn't just about "which library is more popular". It's an analysis of:

  • Your application architecture (Pages vs App Router)
  • Performance requirements
  • Localization complexity
  • Developer Experience

For modern Next.js projects with App Router, next-intl is the obvious choice. It's built specifically for the new architecture, leverages all its advantages, and provides excellent DX.

Don't waste time on reinventing the wheel or using outdated solutions. Use the right tool for the right job.