服务端阅读 06月5日 21:48
Next.js App Router 国际化实战:路由、翻译、SEO 和语言切换
多语言网站不是翻译几个字符串就完事——路由怎么设计、翻译文件怎么组织、SEO 的 hreflang 怎么配、切换语言时状态怎么保持,每个环节都有坑。Next.js App Router 提供了灵活的路由机制,但国际化方案需要自己搭。这篇给出一个完整的实战方案。路由结构设计App Router 推荐用动态路由段 [lang] 承载语言前缀:app/ [lang]/ layout.tsx # 语言布局(加载翻译、设置 lang 属性) page.tsx # 首页 about/ page.tsx # 关于页 layout.tsx # 根布局URL 效果:/zh/about、/en/about、/ja/aboutlayout.tsx 加载翻译并设置语言// app/[lang]/layout.tsximport { notFound } from 'next/navigation'import { dictionaries } from '@/lib/dictionaries'export const supportedLocales = ['zh', 'en', 'ja'] as constexport type Locale = typeof supportedLocales[number]export function generateStaticParams() { return supportedLocales.map((lang) => ({ lang }))}export default async function LangLayout({ children, params: { lang },}: { children: React.ReactNode params: { lang: string }}) { if (!supportedLocales.includes(lang as Locale)) { notFound() } return ( <html lang={lang}> <body>{children}</body> </html> )}generateStaticParams 让 Next.js 在构建时为每种语言生成静态页面。notFound() 处理非法语言前缀。翻译文件组织按语言分文件dictionaries/ zh.json en.json ja.json// dictionaries/zh.json{ "nav": { "home": "首页", "about": "关于", "contact": "联系我们" }, "home": { "title": "欢迎使用我们的产品", "description": "一站式解决方案" }}加载翻译的工具函数// lib/dictionaries.tsconst dictionaries = { zh: () => import('@/dictionaries/zh.json').then((m) => m.default), en: () => import('@/dictionaries/en.json').then((m) => m.default), ja: () => import('@/dictionaries/ja.json').then((m) => m.default),}export type Dictionary = Awaited<ReturnType<typeof dictionaries.zh>>export async function getDictionary(lang: string): Promise<Dictionary> { if (!(lang in dictionaries)) { return dictionaries.zh() // 回退到默认语言 } return dictionaries[lang]()}用动态 import() 按需加载——用户访问 /en/about 时只加载 en.json,不会把所有语言的翻译都打到一个包里。在 Server Component 中使用// app/[lang]/page.tsximport { getDictionary } from '@/lib/dictionaries'export default async function Home({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return ( <main> <h1>{t.home.title}</h1> <p>{t.home.description}</p> </main> )}Server Component 里直接用 await,不需要状态管理。翻译在服务端完成,客户端拿到的就是渲染好的 HTML。语言切换组件// components/lang-switcher.tsx'use client'import { usePathname, useRouter } from 'next/navigation'import { supportedLocales, type Locale } from '@/app/[lang]/layout'const localeNames: Record<Locale, string> = { zh: '中文', en: 'English', ja: '日本語',}export function LangSwitcher({ currentLang }: { currentLang: Locale }) { const pathname = usePathname() const router = useRouter() function switchLang(newLang: Locale) { // 替换 URL 中的语言段 const segments = pathname.split('/') segments[1] = newLang router.push(segments.join('/')) } return ( <select value={currentLang} onChange={(e) => switchLang(e.target.value as Locale)}> {supportedLocales.map((locale) => ( <option key={locale} value={locale}>{localeNames[locale]}</option> ))} </select> )}切换语言就是替换 URL 的第一段——/zh/about 变 /en/about。Next.js 会重新加载对应语言的页面。SEO:hreflang 标签搜索引擎需要知道不同语言版本的对应关系:// app/[lang]/layout.tsx 中添加 metadataexport async function generateMetadata({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return { title: t.home.title, alternates: { canonical: `https://example.com/${lang}`, languages: { 'zh': 'https://example.com/zh', 'en': 'https://example.com/en', 'ja': 'https://example.com/ja', 'x-default': 'https://example.com/en', // 默认语言 }, }, }}x-default 告诉搜索引擎:无法匹配用户语言时,显示这个版本。通常选英语或主要目标语言。中间件:自动重定向到用户首选语言// middleware.tsimport { NextRequest, NextResponse } from 'next/server'import { supportedLocales } from '@/app/[lang]/layout'function getLocale(request: NextRequest): string { const acceptLanguage = request.headers.get('accept-language') if (!acceptLanguage) return 'zh' // 解析 Accept-Language 头,匹配支持的语言 const preferred = acceptLanguage .split(',') .map((lang) => lang.split(';')[0].trim().substring(0, 2).toLowerCase()) return preferred.find((lang) => supportedLocales.includes(lang as any)) || 'zh'}export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // 已有语言前缀,不处理 if (supportedLocales.some((locale) => pathname.startsWith(`/${locale}`))) { return } // 根目录访问,重定向到首选语言 const locale = getLocale(request) request.nextUrl.pathname = `/${locale}${pathname}` return NextResponse.redirect(request.nextUrl)}export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'],}用户首次访问 example.com/about 时,中间件检测到 URL 没有语言前缀,根据浏览器 Accept-Language 重定向到 /en/about 或 /zh/about。Client Component 中使用翻译Client Component 不能直接 await 翻译,需要通过 props 传入或用 Context:// providers/dictionary-provider.tsx'use client'import { createContext, useContext } from 'react'import type { Dictionary } from '@/lib/dictionaries'const DictionaryContext = createContext<Dictionary | null>(null)export function DictionaryProvider({ dictionary, children,}: { dictionary: Dictionary children: React.ReactNode}) { return ( <DictionaryContext.Provider value={dictionary}> {children} </DictionaryContext.Provider> )}export function useDictionary() { const dictionary = useContext(DictionaryContext) if (!dictionary) throw new Error('useDictionary must be used within DictionaryProvider') return dictionary}在 layout 里提供:// app/[lang]/layout.tsximport { DictionaryProvider } from '@/providers/dictionary-provider'export default async function LangLayout({ children, params: { lang } }) { const dictionary = await getDictionary(lang) return ( <DictionaryProvider dictionary={dictionary}> {children} </DictionaryProvider> )}Client Component 里直接 useDictionary() 取翻译,不需要每次 prop drilling。翻译中的插值和复数纯 JSON 翻译文件不支持插值,需要一个小工具函数:// lib/i18n.tsexport function interpolate(template: string, params: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`))}// 复数处理(简单版)export function pluralize(count: number, singular: string, plural: string): string { return count === 1 ? singular : plural}{ "cart": { "items": "购物车中有 {count} 件商品", "item_single": "1 件商品", "item_plural": "{count} 件商品" }}const text = interpolate(t.cart.items, { count: 3 }) // "购物车中有 3 件商品"复杂的复数规则(阿拉伯语、俄语等)建议用 intl-messageformat 库。常见问题翻译键找不到怎么办开发时加一个 fallback 机制:export function t(dictionary: Dictionary, path: string): string { const keys = path.split('.') let result: any = dictionary for (const key of keys) { result = result?.[key] } return result ?? path // 找不到翻译时返回键名,而不是报错}翻译文件太大怎么办按页面拆分翻译文件,按需加载:dictionaries/ zh/ common.json home.json about.json en/ common.json home.json about.jsonSEO 的 hreflang 和 canonical 同时存在冲突吗不冲突。canonical 指向当前语言版本的规范 URL,hreflang 指向其他语言版本。两者配合告诉搜索引擎:这些 URL 是同一个内容的不同语言版本。完整方案清单| 检查项 | 配置 ||--------|------|| 路由结构 | app/[lang]/ 动态路由 || 翻译加载 | 动态 import() 按需加载 || 语言切换 | 替换 URL 语言段 || 自动重定向 | middleware 检测 Accept-Language || SEO | generateMetadata 配置 hreflang + canonical || Client Component | DictionaryProvider + useDictionary || 非法语言 | notFound() 处理 || 翻译插值 | interpolate 工具函数 |