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.
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-intlnext-i18nextnext-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:
| Criterion | next-intl | next-i18next | next-translate | Custom | Weight |
|---|---|---|---|---|---|
| App Router support | ✅ Excellent | ⚠️ Limited | ❌ Pages Only | ✅ Full control | 30% |
| Performance | ✅ Server Components | ⚠️ Client-side | ⚠️ Client-side | ✅ Optimized | 25% |
| Developer Experience | ✅ Excellent | ✅ Good | ⚠️ Average | ❌ Complex | 20% |
| Bundle size | ✅ Small (~5KB) | ⚠️ Medium (~15KB) | ✅ Small (~3KB) | ✅ Minimal | 15% |
| Features | ✅ Rich | ✅ Rich | ⚠️ Basic | ❌ DIY | 10% |
| TOTAL | 9.5/10 | 6.5/10 | 5/10 | 6/10 | 100% |
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-intlfor 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 FCPnext-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:
| Feature | next-intl | next-i18next | next-translate | Custom |
|---|---|---|---|---|
| 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?
- Built for App Router — no hacks or workarounds needed
- Server Components — best performance out of the box
- Excellent DX — typing, autocomplete, clear documentation
- Active development — updates for every Next.js version
- Rich functionality — everything needed for enterprise projects
When NOT to choose next-intl?
- Using Pages Router →
next-i18next - Migrating from
next-i18nextand no time to rewrite → keepnext-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:
- Server Components — translations not in bundle
- Type safety — autocomplete in IDE
- Performance — less JS, faster FCP
- SEO — proper hreflang and alternates
- DX — clear API, great documentation
Useful Links
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.