DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

next-intl: The Complete Next.js i18n Guide (2026)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {}
}
Enter fullscreen mode Exit fullscreen mode

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|.*\\..*).*)',]
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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'),
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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}!"
  }
}
Enter fullscreen mode Exit fullscreen mode
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!"
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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())
})
Enter fullscreen mode Exit fullscreen mode

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',
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)