ByteNoteByteNote

字节笔记本

2026年7月19日

Internationalization in Next.js: A Practical Setup Guide

API中转
¥120

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:

LibraryBased onBest for
next-i18nexti18next + react-i18nextLarge apps that need namespaces, SSR, and flexibility
next-intlReact IntlSmaller projects, cleaner API
next-translateCustom implementationBasic 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

bash
yarn add i18next react-i18next

Translation Files

Organize translations by locale and namespace:

text
public/
  locales/
    en/
      common.json
      order.json
    zh/
      common.json
      order.json

Example common.json:

json
{
  "header": {
    "title": "Welcome",
    "subtitle": "Select your language"
  },
  "nav": {
    "home": "Home",
    "about": "About"
  }
}

Dictionary Loader

Create a server-side dictionary loader:

javascript
// 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

javascript
// 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

javascript
// 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

javascript
'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:

typescript
// 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:

javascript
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 useParams to get the current locale — server components receive it as a prop.
  • Default locale routing: If you want / to serve English without the /en prefix, 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.
分享: