DEV Community

Atlas Whoff
Atlas Whoff

Posted on

i18n in Next.js 14: Internationalization Without the Headache

i18n in Next.js 14: Internationalization Without the Headache

Adding multiple languages to a Next.js App Router app is non-trivial.
Here's the approach that works with App Router without hacks.

The Stack

  • next-intl — translations, plurals, dates/numbers formatting
  • Sub-path routing: /en/dashboard, /de/dashboard
  • Middleware for locale detection and redirect

Setup

npm install next-intl
Enter fullscreen mode Exit fullscreen mode
messages/
  en.json
  de.json
  fr.json
app/
  [locale]/
    layout.tsx
    page.tsx
    dashboard/
      page.tsx
middleware.ts
Enter fullscreen mode Exit fullscreen mode

Translation Files

// messages/en.json
{
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "loading": "Loading..."
  },
  "auth": {
    "signIn": "Sign in",
    "signUp": "Create account",
    "welcomeBack": "Welcome back, {name}!"
  },
  "dashboard": {
    "title": "Dashboard",
    "stats": {
      "users": "{count, plural, =0 {No users} =1 {1 user} other {{count} users}}"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// messages/de.json
{
  "common": {
    "save": "Speichern",
    "cancel": "Abbrechen",
    "loading": "Laden..."
  },
  "auth": {
    "signIn": "Anmelden",
    "signUp": "Konto erstellen",
    "welcomeBack": "Willkommen zuruck, {name}!"
  }
}
Enter fullscreen mode Exit fullscreen mode

Middleware

// middleware.ts
import createMiddleware from 'next-intl/middleware'

export default createMiddleware({
  locales: ['en', 'de', 'fr', 'es'],
  defaultLocale: 'en',
  localePrefix: 'as-needed',  // /en omitted, /de shows
})

export const config = {
  matcher: ['/((?!api|_next|.*\..*).*)'],
}
Enter fullscreen mode Exit fullscreen mode

Root Layout

// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
import { notFound } from 'next/navigation'

const locales = ['en', 'de', 'fr', 'es']

export default async function RootLayout({
  children,
  params: { locale },
}: {
  children: React.ReactNode
  params: { locale: string }
}) {
  if (!locales.includes(locale)) notFound()

  const messages = await getMessages()

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Using Translations

// Server Component
import { getTranslations } from 'next-intl/server'

export default async function DashboardPage() {
  const t = await getTranslations('dashboard')

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('stats.users', { count: 42 })}</p>
      {/* '42 users' */}
    </div>
  )
}

// Client Component
'use client'
import { useTranslations } from 'next-intl'

function SaveButton() {
  const t = useTranslations('common')
  return <button>{t('save')}</button>
}
Enter fullscreen mode Exit fullscreen mode

Language Switcher

'use client'
import { useLocale } from 'next-intl'
import { useRouter, usePathname } from 'next/navigation'

const locales = [
  { code: 'en', name: 'English' },
  { code: 'de', name: 'Deutsch' },
  { code: 'fr', name: 'Francais' },
]

export function LanguageSwitcher() {
  const locale = useLocale()
  const router = useRouter()
  const pathname = usePathname()

  const switchLocale = (newLocale: string) => {
    const newPath = pathname.replace(`/${locale}`, `/${newLocale}`)
    router.push(newPath)
  }

  return (
    <select value={locale} onChange={(e) => switchLocale(e.target.value)}>
      {locales.map(l => (
        <option key={l.code} value={l.code}>{l.name}</option>
      ))}
    </select>
  )
}
Enter fullscreen mode Exit fullscreen mode

Date and Number Formatting

import { useFormatter } from 'next-intl'

function PriceDisplay({ amount, date }: { amount: number; date: Date }) {
  const format = useFormatter()

  return (
    <div>
      {/* $1,299.99 in en, 1.299,99 EUR in de */}
      <p>{format.number(amount, { style: 'currency', currency: 'USD' })}</p>
      {/* Jan 15, 2025 in en, 15. Jan. 2025 in de */}
      <p>{format.dateTime(date, { dateStyle: 'medium' })}</p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The AI SaaS Starter Kit can be extended with i18n — the App Router structure and component patterns are set up for it. $99 one-time.

Top comments (0)