Next.js Internationalization (i18n) — Complete Guide with next-intl

Sanjeev SharmaSanjeev Sharma
5 min read

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-intl

Directory 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.json

Middleware — 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 hreflang alternate 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 hreflang alternate links to all pages for proper search engine indexing
  • Keep translation keys hierarchical (nav.home, not navHome) for maintainability
  • Use ICU message format for pluralization and interpolation — it handles all language edge cases
  • Store translation files in a separate /messages directory 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-Language headers
  • useFormatter() from next-intl provides locale-aware number, date, and relative time formatting
  • Always include hreflang alternate 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro