Adding a second language to an app sounds like a weekend task. In practice, most developers hit the same wall: the routing changes, the translation files become unmanageable, Server Components don't play nicely with i18n libraries, and the type safety disappears the moment you add a dynamic key.
This guide covers next-intl with the Next.js 15 App Router — the combination that solves all of these problems correctly.
The full guide with all code is at stacknotice.com/blog/nextjs-i18n-next-intl-guide-2026
Why next-intl Over the Alternatives
| Library | App Router | Server Components | Type-safe keys | Middleware |
|---|---|---|---|---|
| next-intl | ✅ Full support | ✅ Native | ✅ | ✅ |
| next-i18next | ❌ Pages Router | ❌ | ❌ | ❌ |
| i18next + react-i18next | ⚠️ Manual config | ❌ | ❌ opt-in | Manual |
next-i18next is still widely referenced in older tutorials but it's fundamentally tied to Pages Router. If you're on App Router, it's a dead end. next-intl was designed for App Router from the ground up.
The Architecture: [locale] Routing
The App Router approach to i18n uses a [locale] dynamic segment at the root of the app/ directory:
app/
[locale]/
layout.tsx ← sets lang attribute, wraps NextIntlClientProvider
page.tsx
about/
page.tsx
middleware.ts ← detects user locale, redirects if needed
messages/
en.json
es.json
fr.json
When a user hits /, the middleware reads their Accept-Language header and redirects to /en/ or /es/. The locale is in the URL — shareable, SEO-friendly, and predictable.
Installation
npm install next-intl
Type Safety for Translation Keys
This is the part most tutorials skip:
// global.d.ts
import en from './messages/en.json'
type Messages = typeof en
declare global {
interface IntlMessages extends Messages {}
}
From this point, t('nav.home') autocompletes, and t('nav.typo') is a type error. You can't ship a broken translation key.
Middleware for Locale Detection
// middleware.ts
import createMiddleware from 'next-intl/middleware'
export default createMiddleware({
locales: ['en', 'es', 'fr'],
defaultLocale: 'en',
localePrefix: 'always',
})
export const config = {
matcher: ['/((?!_next|_vercel|.*\\..*).*)',]
}
The middleware handles Accept-Language negotiation, cookie-based persistence, and path prefixing automatically.
Translations in Server Components
No hooks, no providers — just getTranslations:
// app/[locale]/page.tsx
import { getTranslations } from 'next-intl/server'
export default async function HomePage() {
const t = await getTranslations('home.hero')
return (
<main>
<h1>{t('title')}</h1>
<p>{t('subtitle')}</p>
</main>
)
}
For SEO-critical metadata:
export async function generateMetadata({ params: { locale } }) {
const t = await getTranslations({ locale, namespace: 'home.hero' })
return {
title: t('title'),
description: t('subtitle'),
}
}
Every page gets a translated <title> tag. That's proper multilingual SEO.
Translations in Client Components
'use client'
import { useTranslations } from 'next-intl'
export function NavBar() {
const t = useTranslations('nav')
return (
<nav>
<a href="/">{t('home')}</a>
<a href="/about">{t('about')}</a>
</nav>
)
}
The messages travel with the server-rendered HTML — no extra fetch on the client.
Pluralization and Interpolation
next-intl uses ICU message format:
{
"features": {
"count": "{count, plural, =0 {No features yet} one {# feature} other {# features}}"
},
"auth": {
"welcome": "Welcome, {name}!"
}
}
t('features.count', { count: 0 }) // "No features yet"
t('features.count', { count: 1 }) // "1 feature"
t('features.count', { count: 5 }) // "5 features"
t('auth.welcome', { name: user.name }) // "Welcome, Alice!"
Locale Switcher Component
'use client'
import { useLocale } from 'next-intl'
import { useRouter, usePathname } from 'next/navigation'
import { useTransition } from 'react'
export function LocaleSwitcher() {
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const [isPending, startTransition] = useTransition()
function switchLocale(newLocale: string) {
const segments = pathname.split('/')
segments[1] = newLocale
startTransition(() => router.replace(segments.join('/')))
}
return (
<div>
{['en', 'es', 'fr'].map((code) => (
<button
key={code}
onClick={() => switchLocale(code)}
disabled={isPending || code === locale}
>
{code.toUpperCase()}
</button>
))}
</div>
)
}
Date and Number Formatting
import { useFormatter } from 'next-intl'
const format = useFormatter()
format.number(29.99, { style: 'currency', currency: 'USD' })
// en: "$29.99" fr: "29,99 $US"
format.relativeTime(createdAt)
// "3 days ago" | "hace 3 días"
No moment.js, no Intl.DateTimeFormat boilerplate.
Testing Translation Completeness
Catch missing keys before production:
// __tests__/translations.test.ts
import en from '@/messages/en.json'
import es from '@/messages/es.json'
function getKeys(obj, prefix = '') {
return Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'object' && v !== null
? getKeys(v, `${prefix}${k}.`)
: [`${prefix}${k}`]
)
}
it('es has all keys that en has', () => {
expect(getKeys(es).sort()).toEqual(getKeys(en).sort())
})
Add a string to English, forget to translate it to Spanish — the test fails immediately.
hreflang for SEO
export async function generateMetadata() {
return {
alternates: {
languages: {
'en': 'https://yoursite.com/en',
'es': 'https://yoursite.com/es',
'x-default': 'https://yoursite.com/en',
}
}
}
}
Next.js renders <link rel="alternate" hreflang="..."> tags automatically.
next-intl is the library that finally makes i18n in Next.js App Router feel like a first-class feature. Type-safe keys, Server Component support, and middleware-based locale detection — three hard problems solved out of the box.
Full guide at stacknotice.com/blog/nextjs-i18n-next-intl-guide-2026.
Top comments (0)