DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Internationalization in Next.js with next-intl: Routes, Translations, Formatting, and SEO

Internationalization (i18n) and localization (l10n) are often treated as one problem. They're two separate concerns: i18n is engineering (how your app handles multiple languages), l10n is content (what the translations actually say). Get the engineering right first.

next-intl Setup

npm install next-intl
Enter fullscreen mode Exit fullscreen mode
// i18n.ts
import { getRequestConfig } from 'next-intl/server'

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`./messages/${locale}.json`)).default,
}))
Enter fullscreen mode Exit fullscreen mode
// messages/en.json
{
  "nav": {
    "home": "Home",
    "pricing": "Pricing",
    "login": "Log in"
  },
  "pricing": {
    "title": "Simple pricing",
    "monthly": "{price}/month",
    "annual": "{price}/year",
    "cta": "Get started"
  }
}
Enter fullscreen mode Exit fullscreen mode

Route Structure

app/
  [locale]/
    layout.tsx
    page.tsx
    pricing/
      page.tsx
  layout.tsx
middleware.ts
Enter fullscreen mode Exit fullscreen mode
// middleware.ts
import createMiddleware from 'next-intl/middleware'

export default createMiddleware({
  locales: ['en', 'es', 'fr', 'de', 'ja'],
  defaultLocale: 'en',
  localeDetection: true, // detect from Accept-Language header
})

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

Using Translations

// Server Component
import { useTranslations } from 'next-intl'

export default function PricingPage() {
  const t = useTranslations('pricing')

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('monthly', { price: '$29' })}</p>
      <button>{t('cta')}</button>
    </div>
  )
}

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

export function NavBar() {
  const t = useTranslations('nav')
  return <nav><a href='/'>{t('home')}</a></nav>
}
Enter fullscreen mode Exit fullscreen mode

Number and Date Formatting

import { useFormatter } from 'next-intl'

function PriceDisplay({ amount, currency }: { amount: number; currency: string }) {
  const format = useFormatter()

  return (
    <span>
      {format.number(amount, { style: 'currency', currency })}
    </span>
  )
}

// en: $29.00
// de: 29,00 $
// ja: $29.00

function RelativeTime({ date }: { date: Date }) {
  const format = useFormatter()
  return <span>{format.relativeTime(date)}</span>
  // en: '3 days ago'
  // es: 'hace 3 días'
}
Enter fullscreen mode Exit fullscreen mode

SEO with Alternate Links

// app/[locale]/layout.tsx
import { locales } from '@/i18n'

export function generateMetadata({ params: { locale } }) {
  return {
    alternates: {
      languages: Object.fromEntries(
        locales.map(l => [l, `/${l}`])
      ),
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

Managing Translations at Scale

For apps with multiple languages, use a translation management system:

  • Crowdin: Best for open-source and community translation
  • Lokalise: Good CI/CD integration
  • Phrase: Enterprise-grade

Or use AI translation for a fast first pass:

// Generate translation using Claude
async function translateMessages(enMessages: object, targetLocale: string) {
  const response = await anthropic.messages.create({
    model: 'claude-haiku-4-5-20251001',
    system: `Translate this JSON to ${targetLocale}. Keep keys unchanged. Preserve {variable} placeholders.`,
    messages: [{ role: 'user', content: JSON.stringify(enMessages, null, 2) }],
  })
  return JSON.parse(response.content[0].text)
}
Enter fullscreen mode Exit fullscreen mode

The AI SaaS Starter at whoffagents.com ships with next-intl configured for 5 locales, message files, middleware, and formatter utilities ready to use. $99 one-time.

Top comments (0)