字节笔记本
2026年7月19日
Internationalization in Next.js: A Practical Setup Guide
Adding internationalization (i18n) to a Next.js app means every text string in your UI needs to be swappable at runtime based on the user's locale. This post walks through the practical side of setting that up — from picking a library to wiring up type-safe translations and handling the edge cases that documentation tends to gloss over.
Choosing an i18n Library
There are three commonly used options for Next.js:
| Library | Based on | Best for |
|---|---|---|
| next-i18next | i18next + react-i18next | Large apps that need namespaces, SSR, and flexibility |
| next-intl | React Intl | Smaller projects, cleaner API |
| next-translate | Custom implementation | Basic translation needs |
If you're on Next.js 13+ with the App Router, you should use i18next and react-i18next directly rather than next-i18next (which is designed for the Pages Router).
The examples below use i18next directly with the App Router.
Setting Up i18next with App Router
Installation
yarn add i18next react-i18nextTranslation Files
Organize translations by locale and namespace:
public/
locales/
en/
common.json
order.json
zh/
common.json
order.jsonExample common.json:
{
"header": {
"title": "Welcome",
"subtitle": "Select your language"
},
"nav": {
"home": "Home",
"about": "About"
}
}Dictionary Loader
Create a server-side dictionary loader:
// lib/dictionary.js
import 'server-only'
const dictionaries = {
'en': () => import('../public/locales/en/common.json').then(m => m.default),
'zh': () => import('../public/locales/zh/common.json').then(m => m.default),
}
export async function getDictionary(locale) {
return dictionaries[locale]()
}The import('server-only') ensures this module is never bundled into client code.
Middleware for Locale Detection
// middleware.js
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
const LOCALES = ['en', 'zh']
const DEFAULT_LOCALE = 'en'
function getLocale(request) {
const headers = {
'accept-language': request.headers.get('accept-language') || ''
}
const languages = new Negotiator({ headers }).languages()
return match(languages, LOCALES, DEFAULT_LOCALE)
}
export function middleware(request) {
const { pathname } = request.nextUrl
const hasLocale = LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (hasLocale) return
const locale = getLocale(request)
request.nextUrl.pathname = `/${locale}${pathname}`
return Response.redirect(request.nextUrl)
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}Page Implementation
// app/[lang]/page.js
import { getDictionary } from '@/lib/dictionary'
export default async function HomePage({ params: { lang } }) {
const dict = await getDictionary(lang)
return (
<main>
<h1>{dict.header.title}</h1>
<p>{dict.header.subtitle}</p>
</main>
)
}
export async function generateStaticParams() {
return [{ lang: 'en' }, { lang: 'zh' }]
}Language Switcher
'use client'
import { useRouter, useParams } from 'next/navigation'
const LANG_MAP = {
en: { label: 'English', icon: '🇺🇸' },
zh: { label: '中文', icon: '🇨🇳' },
}
export default function LanguageSwitcher() {
const router = useRouter()
const { lang } = useParams()
const handleChange = (newLang) => {
const path = window.location.pathname.replace(`/${lang}`, `/${newLang}`)
router.push(path)
}
return (
<select value={lang} onChange={(e) => handleChange(e.target.value)}>
{Object.entries(LANG_MAP).map(([key, { label, icon }]) => (
<option key={key} value={key}>{icon} {label}</option>
))}
</select>
)
}TypeScript Support
Add types for translation keys:
// types/i18next.d.ts
import 'i18next'
import common from '../public/locales/en/common.json'
interface I18nNamespaces {
common: typeof common
}
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
resources: I18nNamespaces
}
}This gives you autocomplete and type checking on translation keys.
Handling Dates and Numbers
Don't translate these manually — use Intl APIs:
function formatPrice(price, locale) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: locale === 'zh' ? 'CNY' : 'USD',
}).format(price)
}
function formatDate(date, locale) {
return new Intl.DateTimeFormat(locale).format(date)
}Things That Trip People Up
- Client components need
useParamsto get the current locale — server components receive it as a prop. - Default locale routing: If you want
/to serve English without the/enprefix, handle that redirect in middleware. - Missing keys: Decide whether to show the key name, fall back to another locale, or show nothing. Configure this in your i18next init options.
- Bundle size: Load translation files lazily rather than importing all locales upfront.