Next.js Internationalization (i18n) — Complete Guide with next-intl
Advertisement
Introduction
Why This Matters
Internationalization (i18n) opens your application to global audiences. A properly implemented i18n setup uses URL-based locales (/en/blog vs /fr/blog) for SEO discoverability, serves localized metadata for each language, and handles pluralization and number formatting without manual string concatenation.
Installation
npm install next-intlDirectory Structure
app/
[locale]/ ← Locale segment wraps all routes
layout.tsx
page.tsx
blog/
page.tsx
api/
...
middleware.ts ← Locale detection and redirect
messages/
en.json
es.json
fr.json
de.jsonMiddleware — Locale Detection
// middleware.ts
import createMiddleware from 'next-intl/middleware'
export default createMiddleware({
locales: ['en', 'es', 'fr', 'de'],
defaultLocale: 'en',
localePrefix: 'as-needed' // /en/blog → /blog (for default locale)
})
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
}Root Layout with i18n Provider
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
const locales = ['en', 'es', 'fr', 'de'] as const
type Locale = typeof locales[number]
export async function generateStaticParams() {
return locales.map(locale => ({ locale }))
}
export async function generateMetadata({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'metadata' })
return {
title: t('title'),
description: t('description')
}
}
export default async function LocaleLayout({
children,
params: { locale }
}: {
children: React.ReactNode
params: { locale: string }
}) {
if (!locales.includes(locale as Locale)) notFound()
const messages = await getMessages()
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}Translation Files
// messages/en.json
{
"metadata": {
"title": "YourApp — Build something amazing",
"description": "The best platform for modern developers"
},
"nav": {
"home": "Home",
"blog": "Blog",
"about": "About",
"contact": "Contact"
},
"hero": {
"headline": "Build {product} faster",
"cta": "Get Started Free",
"trustedBy": "Trusted by {count, number} developers worldwide"
},
"cart": {
"itemCount": "{count, plural, =0 {No items} =1 {1 item} other {# items}} in cart",
"empty": "Your cart is empty"
}
}// messages/es.json
{
"metadata": {
"title": "YourApp — Construye algo increíble",
"description": "La mejor plataforma para desarrolladores modernos"
},
"nav": {
"home": "Inicio",
"blog": "Blog",
"about": "Acerca de",
"contact": "Contacto"
},
"hero": {
"headline": "Construye {product} más rápido",
"cta": "Comenzar Gratis",
"trustedBy": "Con la confianza de {count, number} desarrolladores en todo el mundo"
},
"cart": {
"itemCount": "{count, plural, =0 {Sin artículos} =1 {1 artículo} other {# artículos}} en el carrito",
"empty": "Tu carrito está vacío"
}
}Using Translations in Server Components
// app/[locale]/page.tsx
import { useTranslations } from 'next-intl'
import { getTranslations } from 'next-intl/server'
// Server Component
export default async function HomePage({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations('hero')
return (
<main>
<h1>{t('headline', { product: 'YourApp' })}</h1>
<p>{t('trustedBy', { count: 12000 })}</p>
<a href="/signup">{t('cta')}</a>
</main>
)
}// Client Component
'use client'
import { useTranslations } from 'next-intl'
export function NavigationMenu() {
const t = useTranslations('nav')
return (
<nav className="flex gap-6">
<a href="/">{t('home')}</a>
<a href="/blog">{t('blog')}</a>
<a href="/about">{t('about')}</a>
<a href="/contact">{t('contact')}</a>
</nav>
)
}Language Switcher
// components/locale-switcher.tsx
'use client'
import { useLocale } from 'next-intl'
import { useRouter, usePathname } from 'next/navigation'
import { startTransition } from 'react'
const LOCALES = [
{ code: 'en', label: 'English', flag: '🇺🇸' },
{ code: 'es', label: 'Español', flag: '🇪🇸' },
{ code: 'fr', label: 'Français', flag: '🇫🇷' },
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' }
]
export function LocaleSwitcher() {
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
function handleChange(newLocale: string) {
startTransition(() => {
// Replace current locale segment in the path
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`) || `/${newLocale}`
router.replace(newPath)
})
}
return (
<select
value={locale}
onChange={e => handleChange(e.target.value)}
className="border rounded px-3 py-1.5 text-sm bg-white"
aria-label="Select language"
>
{LOCALES.map(l => (
<option key={l.code} value={l.code}>
{l.flag} {l.label}
</option>
))}
</select>
)
}Date, Number, and Relative Time Formatting
'use client'
import { useFormatter, useNow } from 'next-intl'
export function LocalizedContent({ price, publishedAt }: { price: number; publishedAt: Date }) {
const format = useFormatter()
const now = useNow({ updateInterval: 60000 })
return (
<div className="space-y-2">
{/* Locale-aware currency */}
<p>{format.number(price, { style: 'currency', currency: 'USD' })}</p>
{/* Locale-aware date */}
<p>{format.dateTime(publishedAt, { dateStyle: 'long' })}</p>
{/* Relative time: "3 minutes ago", "hace 3 minutos" */}
<p>{format.relativeTime(publishedAt, now)}</p>
</div>
)
}Common Mistakes
- Hardcoding locale strings in links — always use the locale prefix consistently
- Not adding
hreflangalternate links in metadata — hurts SEO for multi-language sites - Forgetting pluralization rules — many languages have more than two plural forms
- Using client-side translation without providing messages to
NextIntlClientProvider
Best Practices
- Add
hreflangalternate links to all pages for proper search engine indexing - Keep translation keys hierarchical (
nav.home, notnavHome) for maintainability - Use ICU message format for pluralization and interpolation — it handles all language edge cases
- Store translation files in a separate
/messagesdirectory and never hardcode user-visible strings
Key Takeaways
- next-intl is the standard i18n solution for Next.js App Router with server and client component support
- Use URL-based locale routing (
/en/blog,/fr/blog) — it is SEO-friendly and user-bookmarkable getTranslations()is for Server Components;useTranslations()is for Client Components- ICU message format handles pluralization, variables, and gender-specific text for all languages
- The middleware detects and redirects to the correct locale based on
Accept-Languageheaders useFormatter()from next-intl provides locale-aware number, date, and relative time formatting- Always include
hreflangalternate links in page<head>to signal alternate language versions to search engines - Test with real locale data — especially pluralization, which differs significantly across languages
Advertisement