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
messages/
en.json
de.json
fr.json
app/
[locale]/
layout.tsx
page.tsx
dashboard/
page.tsx
middleware.ts
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}}"
}
}
}
// messages/de.json
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"loading": "Laden..."
},
"auth": {
"signIn": "Anmelden",
"signUp": "Konto erstellen",
"welcomeBack": "Willkommen zuruck, {name}!"
}
}
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|.*\..*).*)'],
}
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>
)
}
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>
}
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>
)
}
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>
)
}
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)