Next.js 国际化与静态生成:SSG 多语言网站实战

说实话,我第一次在 Next.js App Router 项目里尝试做多语言静态生成的时候,真的是踩了一堆坑。你可能也有过类似的经历:明明按照文档配置了,结果一 build 就报错;或者好不容易构建成功了,却发现花了 15 分钟才生成完所有页面…
让我先分享几个我遇到过的典型场景,看看你有没有似曾相识的感觉。
你是否也遇到过这些问题?
场景一:构建时报错
记得有次我兴冲冲地运行 npm run build,结果终端直接给我抛了个错:
Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.当时我整个人都懵了,心想:“什么鬼?我明明在 next.config.js 里配置了 i18n 啊!” 后来才发现,App Router 和 Pages Router 的国际化方式完全不一样,之前那套配置根本不管用了。
场景二:构建时间过长
还有一次,我的项目支持 6 种语言,每种语言大概 50 个页面。结果一次构建下来:
✓ Generating static pages (152/152) - 15m 32s15 分钟!你没看错。每次改点小东西都要等这么久,开发体验简直崩溃。我当时就在想:“这要是上了生产环境,CI/CD 不得等到天荒地老?“
场景三:翻译更新不生效
最让人抓狂的是这个:我更新了 zh-CN.json 翻译文件,重新构建、重新部署,结果网站上还是显示旧的翻译!必须清除浏览器缓存才能看到新内容。这在生产环境简直是灾难,用户看到的都是过期内容。
问题的根源在哪?
后来我花了不少时间研究,才搞明白这些问题的本质:
App Router 不再支持 Pages Router 的 i18n 配置 - 这是最大的坑。你在
next.config.js里配置i18n字段,App Router 根本不认。静态导出与动态渲染的冲突 - 当你设置
output: 'export'时,Next.js 要求所有页面必须在构建时确定。如果你用了cookies()、headers()这些动态 API,就会报错。翻译文件的缓存机制 - Next.js 会缓存导入的 JSON 文件,开发时更新了翻译,但缓存没失效,所以看不到最新内容。
如果你也遇到过这些问题,那这篇文章就是为你准备的。接下来我会一步步教你怎么正确实现 Next.js App Router 的多语言静态生成,并且避免这些坑。
理解 App Router 的 i18n 新范式
在开始写代码之前,我觉得有必要先理解一下 App Router 的国际化思路。这跟 Pages Router 真的很不一样。
Pages Router vs App Router:天差地别的两种方案
我做了个对比表,你可以直观感受一下区别:
| 特性 | Pages Router | App Router |
|---|---|---|
| 配置方式 | next.config.js 的 i18n 字段 | middleware + 动态路由 [lang] |
| 路由结构 | 自动生成 /en/、/zh/ 前缀 | 手动创建 app/[lang]/page.tsx |
| 静态生成 | 使用 getStaticPaths | 使用 generateStaticParams |
| 翻译加载 | serverSideTranslations 函数 | 服务端组件直接 import JSON |
看到了吗?几乎所有环节都变了。我第一次遇到这个的时候,真的有种”我学的是假 Next.js”的感觉。
generateStaticParams 到底是什么?
这是 App Router 里最核心的概念之一。简单来说,它的作用就是告诉 Next.js:“我需要为哪些参数生成静态页面”。
举个例子:
// app/[lang]/layout.tsx
export async function generateStaticParams() {
// 返回所有需要预渲染的语言参数
return [
{ lang: 'en' },
{ lang: 'zh' },
{ lang: 'ja' }
]
}Next.js 在构建时会执行这个函数,拿到返回的参数列表,然后为每个参数组合生成一个静态 HTML 文件。最终输出就是:
out/
├── en/
│ └── index.html
├── zh/
│ └── index.html
└── ja/
└── index.html关键点:这个函数必须在 layout.tsx 或 page.tsx 中定义,而且函数名必须完全匹配(不是 getStaticParams,不是 generateParams,必须是 generateStaticParams)。
翻译文件怎么加载?
Pages Router 时代,我们用 next-i18next 库的 serverSideTranslations 函数。但在 App Router 里,你可以直接在服务端组件里导入翻译文件:
// 服务端组件可以直接这样做
import enTranslations from '@/i18n/locales/en/common.json'
import zhTranslations from '@/i18n/locales/zh-CN/common.json'
const translations = {
'en': enTranslations,
'zh-CN': zhTranslations,
}
export default function Page({ params }: { params: { lang: string } }) {
const t = translations[params.lang]
return <h1>{t.title}</h1>
}但这样有个问题:所有语言的翻译都会打包进 bundle,导致文件变大。所以实际项目中,我们通常会写一个加载函数:
// i18n/utils.ts
export async function loadTranslations(locale: string, namespaces: string[]) {
const translations: Record<string, any> = {}
for (const ns of namespaces) {
try {
const translation = await import(`@/i18n/locales/${locale}/${ns}.json`)
translations[ns] = translation.default
} catch (error) {
console.warn(`Translation file not found: ${locale}/${ns}`)
translations[ns] = {}
}
}
return translations
}这样可以按需加载,只加载当前页面需要的翻译命名空间。
实战:从零搭建多语言 SSG 项目
好了,理论讲完了,现在开始撸代码。我会从头到尾带你搭建一个完整的多语言静态站点。
第一步:设计项目结构
首先,我们需要建立一个清晰的目录结构。这是我在实际项目中用的结构,亲测好用:
app/
├── [lang]/ # 语言动态路由(核心)
│ ├── layout.tsx # 根布局,包含 generateStaticParams
│ ├── page.tsx # 首页
│ ├── about/
│ │ └── page.tsx # 关于页面
│ └── blog/
│ ├── page.tsx # 博客列表
│ └── [slug]/
│ └── page.tsx # 博客详情(嵌套动态路由)
├── i18n/
│ ├── locales/ # 翻译文件目录
│ │ ├── en/
│ │ │ ├── common.json # 公共翻译
│ │ │ ├── home.json # 首页翻译
│ │ │ └── blog.json # 博客翻译
│ │ ├── zh-CN/
│ │ │ ├── common.json
│ │ │ ├── home.json
│ │ │ └── blog.json
│ │ └── ja/
│ │ ├── common.json
│ │ ├── home.json
│ │ └── blog.json
│ ├── config.ts # i18n 配置文件
│ └── utils.ts # 翻译工具函数
└── middleware.ts # 语言检测和重定向为什么这样设计?
[lang]文件夹:这是动态路由的核心,Next.js 会把 URL 中的语言参数传递给页面组件。- 按命名空间划分翻译:避免一个翻译文件太大,按页面功能分开,按需加载。
config.ts集中配置:所有语言相关的配置都放这里,方便维护。
第二步:配置 i18n 核心文件
先来写配置文件,这是整个系统的基础:
// i18n/config.ts
export const i18nConfig = {
// 支持的语言列表
locales: ['en', 'zh-CN', 'ja'],
// 默认语言
defaultLocale: 'en',
// 路径前缀策略
// 'always': 所有语言都带前缀 /en/、/zh-CN/
// 'as-needed': 默认语言不带前缀,其他语言带前缀
localePrefix: 'always',
// 【重要】只预渲染主要语言(优化构建时间)
localesToPrerender: process.env.NODE_ENV === 'production'
? ['en', 'zh-CN'] // 生产环境只预渲染英文和中文
: ['en'], // 开发环境只渲染默认语言
} as const
// 导出类型,供 TypeScript 类型检查使用
export type Locale = (typeof i18nConfig)['locales'][number]
// 翻译命名空间(用于代码分割)
export const namespaces = ['common', 'home', 'about', 'blog'] as const
export type Namespace = (typeof namespaces)[number]关键点解释:
as const:这是 TypeScript 的写法,确保类型是精确的字面量类型,而不是宽泛的string[]。localesToPrerender:这个很重要!如果你支持 10 种语言,但只预渲染 2 种主要语言,构建时间能减少 80%。其他语言可以通过 ISR(增量静态再生)或按需生成。- 命名空间:把翻译文件拆分成多个 JSON,避免首次加载下载一个巨大的翻译文件。
第三步:实现翻译加载工具
这是一个简单但实用的翻译加载器:
// i18n/utils.ts
import type { Locale, Namespace } from './config'
// 翻译文件缓存(避免重复读取)
const translationsCache = new Map<string, any>()
/**
* 加载指定语言的翻译文件
*
* @param locale 语言代码,如 'en'、'zh-CN'
* @param namespaces 翻译命名空间数组,如 ['common', 'home']
* @returns 翻译对象 { common: {...}, home: {...} }
*/
export async function loadTranslations(
locale: Locale,
namespaces: Namespace[]
) {
const translations: Record<string, any> = {}
for (const namespace of namespaces) {
const cacheKey = `${locale}-${namespace}`
// 检查缓存,避免重复加载
if (!translationsCache.has(cacheKey)) {
try {
// 动态导入翻译文件
const translation = await import(
`@/i18n/locales/${locale}/${namespace}.json`
)
translationsCache.set(cacheKey, translation.default)
} catch (error) {
console.warn(`⚠️ Translation file not found: ${locale}/${namespace}.json`)
translationsCache.set(cacheKey, {})
}
}
translations[namespace] = translationsCache.get(cacheKey)
}
return translations
}
/**
* 创建类型安全的翻译函数
*
* 用法:
* const t = createTranslator(translations)
* t('common.nav.home')
* t('home.welcome', { name: 'John' }) // 支持变量替换
*/
export function createTranslator(translations: any) {
return (key: string, params?: Record<string, string>) => {
const keys = key.split('.')
let value = translations
// 逐层访问嵌套属性
for (const k of keys) {
value = value?.[k]
}
// 找不到翻译时返回 key 本身(便于调试)
if (!value) {
console.warn(`⚠️ Translation missing: ${key}`)
return key
}
// 支持变量替换:将 {{name}} 替换为实际值
if (params) {
return Object.entries(params).reduce(
(str, [key, val]) => str.replace(`{{${key}}}`, val),
value
)
}
return value
}
}这个工具的亮点:
- 缓存机制:第一次加载后就缓存起来,避免重复读文件。
- 错误处理:翻译文件找不到时不会崩溃,只是警告并返回空对象。
- 变量替换:支持在翻译中使用
{{变量名}}占位符。 - 类型友好:配合 TypeScript 可以做到类型安全的翻译 key 检查。
第四步:创建根布局(最关键)
这是整个多语言系统的核心文件:
// app/[lang]/layout.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'
/**
* 【核心】生成所有语言的静态参数
*
* 这个函数在构建时执行,Next.js 会根据返回值生成对应的静态页面
*
* 重要提示:
* 1. 函数名必须是 generateStaticParams(不能拼错)
* 2. 必须在 layout.tsx 或 page.tsx 中定义
* 3. 返回的参数名必须与路由文件夹名匹配([lang] → lang)
*/
export async function generateStaticParams() {
console.log(`🌍 Generating static params for ${i18nConfig.localesToPrerender.length} locales...`)
return i18nConfig.localesToPrerender.map((locale) => ({
lang: locale, // ⚠️ 注意:必须是 'lang' 而不是 'locale'
}))
}
/**
* 根布局组件
*
* 这个组件会包裹所有页面,用于设置全局配置
*/
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: { lang: string }
}) {
// 加载公共翻译(导航、页脚等)
const translations = await loadTranslations(params.lang as Locale, ['common'])
return (
<html
lang={params.lang}
// 如果是阿拉伯语,设置从右到左的布局
dir={params.lang === 'ar' ? 'rtl' : 'ltr'}
>
<head>
{/* 在这里可以添加全局 meta 标签 */}
</head>
<body>
{/* 这里可以放导航栏、页脚等全局组件 */}
{children}
</body>
</html>
)
}
/**
* 生成元数据(SEO)
*
* 这个函数用于生成页面的 <title>、<meta> 等标签
*/
export async function generateMetadata({ params }: { params: { lang: string } }) {
return {
// 设置语言相关的 meta 标签
alternates: {
canonical: `https://example.com/${params.lang}`,
languages: {
'en': 'https://example.com/en',
'zh-CN': 'https://example.com/zh-CN',
'ja': 'https://example.com/ja',
},
},
// Open Graph 标签(用于社交媒体分享)
openGraph: {
locale: params.lang,
alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
},
}
}这里有几个容易踩的坑:
⚠️ 坑 1:参数名必须匹配
// ❌ 错误:参数名是 locale,但路由文件夹是 [lang]
export async function generateStaticParams() {
return [{ locale: 'en' }] // 这样会报错
}
// ✅ 正确:参数名与文件夹名一致
export async function generateStaticParams() {
return [{ lang: 'en' }] // 必须是 lang
}⚠️ 坑 2:不能使用动态 API
// ❌ 错误:在静态生成的页面中使用 cookies
export default async function Layout({ children }) {
const locale = cookies().get('NEXT_LOCALE') // 这会导致构建失败
return <html lang={locale}>{children}</html>
}
// ✅ 正确:使用路由参数
export default async function Layout({ children, params }) {
return <html lang={params.lang}>{children}</html>
}第五步:创建翻译文件
翻译文件的结构也很重要。这是我推荐的格式:
// i18n/locales/zh-CN/common.json
{
"nav": {
"home": "首页",
"about": "关于",
"blog": "博客",
"contact": "联系"
},
"footer": {
"copyright": "© {{year}} 版权所有",
"privacy": "隐私政策",
"terms": "服务条款"
},
"actions": {
"readMore": "阅读更多",
"backToTop": "返回顶部",
"share": "分享",
"edit": "编辑"
},
"messages": {
"loading": "加载中...",
"error": "出错了",
"success": "操作成功",
"noResults": "没有找到结果"
}
}// i18n/locales/zh-CN/blog.json
{
"title": "博客文章",
"publishedAt": "发布于",
"author": "作者",
"tags": "标签",
"relatedPosts": "相关文章",
"readingTime": "阅读时间:{{minutes}} 分钟",
"shareOn": "分享到 {{platform}}"
}翻译文件的最佳实践:
- 分层结构:用嵌套对象组织翻译,不要把所有键都放在顶层。
- 变量占位符:使用
{{变量名}}格式,便于统一处理。 - 保持键名一致:所有语言的翻译文件应该有相同的键结构。
- 添加注释:在复杂的翻译旁边添加注释,说明使用场景。
第六步:处理嵌套动态路由
如果你的项目有博客或产品详情页,就需要处理嵌套的动态路由。这是我踩过最大的坑之一。
// app/[lang]/blog/[slug]/page.tsx
import { i18nConfig } from '@/i18n/config'
import { loadTranslations, createTranslator } from '@/i18n/utils'
import type { Locale } from '@/i18n/config'
// 假设你有这些辅助函数(实际项目中需要自己实现)
async function getBlogSlugs(): Promise<string[]> {
// 从文件系统或 CMS 获取所有博客文章的 slug
return ['getting-started', 'advanced-tips', 'performance-guide']
}
async function getBlogPost(slug: string, locale: Locale) {
// 获取特定语言的博客文章内容
// ...
}
/**
* 【关键】嵌套路由的 generateStaticParams
*
* 需要生成 语言 × 文章 的所有组合
* 例如:en/getting-started, zh-CN/getting-started, en/advanced-tips...
*/
export async function generateStaticParams() {
const startTime = Date.now()
console.log('📝 Generating blog post params...')
// 获取所有文章的 slug(只需要一次请求)
const slugs = await getBlogSlugs()
// 使用 flatMap 生成所有语言和文章的组合
const params = i18nConfig.localesToPrerender.flatMap((locale) =>
slugs.map((slug) => ({
lang: locale,
slug: slug,
}))
)
const duration = Date.now() - startTime
console.log(`✅ Generated ${params.length} blog post params in ${duration}ms`)
return params
}
/**
* 博客文章页面组件
*/
export default async function BlogPost({
params,
}: {
params: { lang: string; slug: string }
}) {
// 加载翻译和文章内容
const [translations, post] = await Promise.all([
loadTranslations(params.lang as Locale, ['common', 'blog']),
getBlogPost(params.slug, params.lang as Locale),
])
const t = createTranslator(translations)
return (
<article className="prose">
<h1>{post.title}</h1>
<p className="text-gray-600">
{t('blog.publishedAt')}: {new Date(post.date).toLocaleDateString(params.lang)}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}性能优化关键点:
这里有个容易犯的错误。你可能会想为每种语言单独获取数据:
// ❌ 错误做法:多次请求,构建慢
export async function generateStaticParams() {
const results = []
for (const locale of i18nConfig.localesToPrerender) {
// 每种语言都请求一次数据库或 CMS,太慢了!
const slugs = await getBlogSlugs(locale)
results.push(...slugs.map(slug => ({ lang: locale, slug })))
}
return results
}正确的做法是只请求一次数据,然后用 flatMap 生成组合:
// ✅ 正确做法:一次请求,快速生成
export async function generateStaticParams() {
// 只请求一次数据
const slugs = await getBlogSlugs()
// 用 flatMap 生成所有语言 × 文章的组合
return i18nConfig.localesToPrerender.flatMap((locale) =>
slugs.map((slug) => ({ lang: locale, slug }))
)
}在我的项目里,这个优化让构建时间从 18 分钟降到了 6 分钟,效果非常明显!
第七步:实现 Middleware 语言检测
Middleware 的作用是自动检测用户的语言偏好,并重定向到对应的语言版本。
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { i18nConfig } from './i18n/config'
/**
* Middleware 中间件
*
* 这个函数会在每个请求之前执行,用于:
* 1. 检测用户的语言偏好
* 2. 重定向到对应的语言路径
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// 检查路径是否已经包含语言前缀
const pathnameHasLocale = i18nConfig.locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
// 如果已经有语言前缀,直接放行
if (pathnameHasLocale) return
// 获取用户的首选语言
const locale = getLocale(request) ?? i18nConfig.defaultLocale
// 重定向到带语言前缀的路径
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
/**
* 语言检测函数
*
* 优先级:
* 1. Cookie 中保存的语言偏好
* 2. Accept-Language 请求头
* 3. 返回 null,使用默认语言
*/
function getLocale(request: NextRequest): string | null {
// 优先级 1:检查 Cookie
const localeCookie = request.cookies.get('NEXT_LOCALE')?.value
if (localeCookie && i18nConfig.locales.includes(localeCookie as any)) {
return localeCookie
}
// 优先级 2:检查 Accept-Language 请求头
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
// Accept-Language 格式:zh-CN,zh;q=0.9,en;q=0.8
const preferred = acceptLanguage.split(',')[0].split('-')[0]
const match = i18nConfig.locales.find(locale =>
locale.toLowerCase().startsWith(preferred.toLowerCase())
)
if (match) return match
}
// 没有找到匹配的语言,返回 null
return null
}
/**
* Middleware 配置
*
* matcher 定义了哪些路径需要执行 middleware
*/
export const config = {
// 匹配所有路径,除了:
// - /api 开头的 API 路由
// - /_next/static 静态文件
// - /_next/image 图片
// - /favicon.ico 等静态资源
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}Middleware 的使用场景:
假设用户直接访问 https://example.com/blog,Middleware 会:
- 检查 Cookie 中是否有保存的语言偏好(比如用户上次选择了中文)
- 如果没有,检查浏览器的
Accept-Language头(浏览器会自动发送用户的系统语言) - 根据检测结果,重定向到
https://example.com/zh-CN/blog或https://example.com/en/blog
这样就实现了自动语言检测,用户体验更好。
第八步:创建语言切换器组件
最后,我们需要一个语言切换器,让用户可以手动切换语言:
// components/LanguageSwitcher.tsx
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { i18nConfig } from '@/i18n/config'
import type { Locale } from '@/i18n/config'
// 语言显示名称映射
const localeNames: Record<Locale, string> = {
'en': 'English',
'zh-CN': '简体中文',
'ja': '日本語',
}
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname()
const router = useRouter()
const handleLocaleChange = (newLocale: Locale) => {
// 保存语言偏好到 Cookie
document.cookie = `NEXT_LOCALE=${newLocale};path=/;max-age=31536000`
// 替换路径中的语言前缀
// 例如:/zh-CN/blog → /en/blog
const newPathname = pathname.replace(`/${currentLocale}`, `/${newLocale}`)
// 跳转到新的语言版本
router.push(newPathname)
}
return (
<div className="relative">
<select
value={currentLocale}
onChange={(e) => handleLocaleChange(e.target.value as Locale)}
className="px-4 py-2 border rounded-lg"
>
{i18nConfig.locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</div>
)
}在导航栏中使用:
// components/Navigation.tsx
import { LanguageSwitcher } from './LanguageSwitcher'
export function Navigation({ lang }: { lang: string }) {
return (
<nav className="flex items-center justify-between p-4">
<div className="flex gap-4">
<a href={`/${lang}/`}>首页</a>
<a href={`/${lang}/about`}>关于</a>
<a href={`/${lang}/blog`}>博客</a>
</div>
<LanguageSwitcher currentLocale={lang} />
</nav>
)
}性能优化:让构建速度飞起来
现在基础功能都实现了,但如果你的网站支持多种语言,构建时间可能会很长。让我分享几个实用的优化技巧。
优化 1:选择性预渲染
这是最有效的优化。如果你支持 10 种语言,但实际流量集中在 2-3 种主要语言,那就只预渲染主要语言:
// i18n/config.ts
export const i18nConfig = {
// 支持的所有语言
locales: ['en', 'zh-CN', 'ja', 'ko', 'de', 'fr', 'es', 'pt'],
defaultLocale: 'en',
// 【关键】只预渲染主要语言
localesToPrerender: process.env.NODE_ENV === 'production'
? ['en', 'zh-CN'] // 生产环境:只预渲染英文和中文
: ['en'], // 开发环境:只渲染默认语言(加快开发)
}效果对比:
| 配置 | 构建时间 | 说明 |
|---|---|---|
| 预渲染 8 种语言 | ~24 分钟 | 所有语言都生成静态页面 |
| 预渲染 2 种语言 | ~6 分钟 | 其他语言首次访问时生成 |
| 只渲染 1 种语言 | ~3 分钟 | 开发环境推荐 |
节省了 75% 的构建时间!
优化 2:使用增量静态再生(ISR)
对于不太重要的语言或不常访问的页面,可以使用 ISR 按需生成:
// app/[lang]/blog/[slug]/page.tsx
// 开启 ISR,1 小时后重新验证
export const revalidate = 3600
export async function generateStaticParams() {
const slugs = await getBlogSlugs()
// 只预渲染主要语言的热门文章
const topSlugs = slugs.slice(0, 10) // 只预渲染前 10 篇
return i18nConfig.localesToPrerender.flatMap((locale) =>
topSlugs.map((slug) => ({ lang: locale, slug }))
)
}
// 【重要】允许动态生成未预渲染的页面
export const dynamicParams = true这样配置后:
- 构建时只生成 2 种语言 × 10 篇文章 = 20 个页面
- 用户访问未预渲染的页面时,Next.js 会实时生成并缓存
- 缓存 1 小时后自动更新
优化 3:并行获取数据
在 generateStaticParams 中,如果需要获取多种数据,一定要并行处理:
// ❌ 错误:串行获取(慢)
export async function generateStaticParams() {
const posts = await getBlogPosts() // 等待 2 秒
const categories = await getCategories() // 等待 1 秒
// 总共 3 秒
}
// ✅ 正确:并行获取(快)
export async function generateStaticParams() {
const [posts, categories] = await Promise.all([
getBlogPosts(), // 同时执行
getCategories(), // 同时执行
])
// 总共 2 秒(取最长的那个)
}在我的项目里,这个优化让数据获取时间减少了 40%。
优化 4:解决翻译缓存问题
开发时最烦的就是更新了翻译文件,但页面不刷新。这是因为 Next.js 缓存了导入的 JSON 文件。
解决方案:开发环境禁用缓存
// i18n/utils.ts
import fs from 'fs/promises'
import path from 'path'
const isDev = process.env.NODE_ENV === 'development'
export async function loadTranslations(
locale: Locale,
namespaces: Namespace[]
) {
// 开发环境:每次都重新读取文件
if (isDev) {
const translations: Record<string, any> = {}
for (const ns of namespaces) {
const filePath = path.join(
process.cwd(),
'i18n',
'locales',
locale,
`${ns}.json`
)
try {
const content = await fs.readFile(filePath, 'utf-8')
translations[ns] = JSON.parse(content)
} catch (error) {
console.warn(`Translation file not found: ${filePath}`)
translations[ns] = {}
}
}
return translations
}
// 生产环境:使用缓存
return loadTranslationsWithCache(locale, namespaces)
}这样一来,开发时更新翻译文件后刷新页面就能看到最新内容了。
常见问题排查指南
在实际开发中,你可能还会遇到一些其他问题。这里我整理了最常见的几个,以及我的解决方法。
问题 1:构建时报错 “generateStaticParams not found”
错误信息:
Error: Page "/en/about" is missing `generateStaticParams()`
so it cannot be used with `output: "export"`.排查步骤:
- ✅ 检查是否在
layout.tsx或page.tsx中定义了generateStaticParams - ✅ 确认函数名拼写正确(不是
getStaticParams,不是generateParams) - ✅ 确认函数正确导出(必须是
export async function) - ✅ 检查参数名是否与路由文件夹名匹配
// ❌ 错误示例
export async function getStaticParams() { // 函数名错了
return [{ locale: 'en' }] // 参数名也错了
}
// ✅ 正确示例
export async function generateStaticParams() {
return [{ lang: 'en' }] // 参数名必须与 [lang] 匹配
}问题 2:Dynamic rendering detected
错误信息:
Error: Route /[lang]/about couldn't be rendered statically
because it used `headers` or `cookies`.原因:在静态生成的页面中使用了动态 API(headers()、cookies()、searchParams)。
解决方案:
// ❌ 错误:在服务端组件使用 cookies
export default async function Page() {
const locale = cookies().get('NEXT_LOCALE') // 触发动态渲染
return <div>...</div>
}
// ✅ 方案 1:在 middleware 中处理
// middleware.ts
export function middleware(request: NextRequest) {
const locale = request.cookies.get('NEXT_LOCALE')
// 处理逻辑...
}
// ✅ 方案 2:使用客户端组件
'use client'
export function LanguageSwitcher() {
const [locale, setLocale] = useState(() => {
// 在客户端读取 Cookie
return getCookie('NEXT_LOCALE')
})
// ...
}问题 3:Translation file not found
错误信息:
Error: Cannot find module './locales/en/common.json'排查清单:
- ✅ 检查文件路径是否正确(注意大小写,Linux 区分大小写)
- ✅ 确认 JSON 文件语法正确(可以用在线工具验证)
- ✅ 检查
tsconfig.json的路径别名配置:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}- ✅ 确认翻译文件在构建时被正确包含:
// next.config.js
module.exports = {
// 确保 JSON 文件被包含
webpack: (config) => {
config.module.rules.push({
test: /\.json$/,
type: 'json',
})
return config
},
}问题 4:语言切换后路由参数丢失
现象:从 /zh-CN/blog/my-post 切换到英文后,跳转到了 /en/ 而不是 /en/blog/my-post。
原因:语言切换器没有正确保留路由参数。
解决方案:
// ❌ 错误:硬编码路径
<Link href="/about">About</Link>
// ✅ 方案 1:手动拼接语言参数
<Link href={`/${params.lang}/about`}>About</Link>
// ✅ 方案 2:封装一个智能的 Link 组件
// components/LocalizedLink.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export function LocalizedLink({
href,
children,
...props
}: {
href: string
children: React.ReactNode
[key: string]: any
}) {
const pathname = usePathname()
// 从当前路径提取语言
const locale = pathname.split('/')[1]
// 自动添加语言前缀
const localizedHref = `/${locale}${href}`
return (
<Link href={localizedHref} {...props}>
{children}
</Link>
)
}问题 5:SEO 标签缺失或不正确
问题:多语言页面的 SEO 标签(hreflang、canonical)配置不当,影响搜索引擎收录。
解决方案:在每个页面的 generateMetadata 中正确配置:
// app/[lang]/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: { lang: string; slug: string }
}) {
const baseUrl = 'https://example.com'
return {
// 页面标题和描述
title: 'My Blog Post',
description: 'This is a blog post',
// Canonical URL(规范链接)
alternates: {
canonical: `${baseUrl}/${params.lang}/blog/${params.slug}`,
// hreflang 标签(告诉搜索引擎其他语言版本)
languages: {
'en': `${baseUrl}/en/blog/${params.slug}`,
'zh-CN': `${baseUrl}/zh-CN/blog/${params.slug}`,
'ja': `${baseUrl}/ja/blog/${params.slug}`,
'x-default': `${baseUrl}/en/blog/${params.slug}`, // 默认语言
},
},
// Open Graph 标签(用于社交媒体分享)
openGraph: {
title: 'My Blog Post',
description: 'This is a blog post',
url: `${baseUrl}/${params.lang}/blog/${params.slug}`,
locale: params.lang,
alternateLocale: i18nConfig.locales.filter(l => l !== params.lang),
},
}
}最佳实践总结
经过这么多实践,我总结了一些可以直接执行的最佳实践清单。
项目初始化清单
在开始开发之前,确保完成这些配置:
- 确定支持的语言列表和默认语言
- 创建
app/[lang]目录结构 - 配置
i18n/config.ts和翻译文件目录 - 实现
middleware.ts语言检测 - 在根布局添加
generateStaticParams - 配置
next.config.js(如需静态导出,设置output: 'export')
开发阶段建议
- 开发环境只预渲染默认语言(
localesToPrerender: ['en']) - 使用 TypeScript 确保翻译 key 的类型安全
- 按功能模块划分翻译命名空间(common、home、blog…)
- 开发环境禁用翻译缓存(使用
fs.readFile实时读取) - 添加缺失翻译的警告日志(便于发现问题)
生产部署清单
- 选择性预渲染主要语言(优化构建时间)
- 配置 ISR 策略(次要语言按需生成,设置
revalidate) - 使用并行数据获取(
Promise.all) - 配置正确的 hreflang 和 canonical 标签
- 设置 CDN 缓存策略(考虑多语言路径)
- 监控各语言版本的访问量和构建时间
next.config.js 完整配置示例
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// 静态导出(如果需要)
output: 'export',
// 图片优化配置
images: {
unoptimized: true, // 静态导出时需要
},
// 环境变量
env: {
BUILD_TIME: new Date().toISOString(),
},
// 自定义构建 ID(用于缓存失效)
generateBuildId: async () => {
return `build-${Date.now()}`
},
// Webpack 配置
webpack: (config, { isServer }) => {
// 确保 JSON 文件正确处理
config.module.rules.push({
test: /\.json$/,
type: 'json',
})
return config
},
}
module.exports = nextConfig推荐的工具和库
如果你不想从零实现,可以考虑用这些现成的库:
| 工具/库 | 用途 | 推荐指数 | 说明 |
|---|---|---|---|
| next-intl | 完整的 i18n 解决方案 | ⭐⭐⭐⭐⭐ | 官方推荐,功能最全,支持 App Router |
| next-international | 轻量级 i18n 库 | ⭐⭐⭐⭐ | 轻量简洁,类型安全 |
| @formatjs/intl | 国际化格式化 | ⭐⭐⭐⭐ | 处理日期、数字、货币等格式 |
| typesafe-i18n | 类型安全的翻译 | ⭐⭐⭐⭐ | 自动生成类型定义 |
| i18next | 老牌 i18n 库 | ⭐⭐⭐ | 功能强大但需要适配 App Router |
我个人推荐 next-intl,它是专门为 Next.js App Router 设计的,开箱即用,不需要自己折腾那么多配置。但如果你想深入理解 i18n 的实现原理,或者需要高度定制化,那么手动实现(就像本文这样)也是个不错的选择。
总结
回顾一下,我们实现了一个完整的 Next.js App Router 多语言静态生成方案,包括:
核心功能
- 基于
[lang]动态路由的多语言结构 - 使用
generateStaticParams生成静态页面 - 按命名空间划分的翻译文件系统
- Middleware 自动语言检测和重定向
- 基于
性能优化
- 选择性预渲染主要语言(减少 75% 构建时间)
- 使用 ISR 按需生成次要语言
- 并行数据获取
- 开发环境禁用缓存
问题解决
generateStaticParams配置错误- 动态 API 导致构建失败
- 翻译文件缓存问题
- 语言切换路由丢失
- SEO 标签配置
关键要点:
- App Router 的 i18n 需要手动实现,不能用 Pages Router 的配置
generateStaticParams必须在 layout 或 page 中定义,参数名要匹配- 静态生成的页面不能使用
cookies()、headers()等动态 API - 合理使用选择性预渲染和 ISR,避免构建时间过长
如果你也在用 Next.js App Router 做多语言站点,希望这篇文章能帮你少踩一些坑。其实国际化本身并不复杂,关键是理解 Next.js 的构建机制,然后按照它的规则来配置。
最后,如果你觉得手动实现太麻烦,记得试试 next-intl 这个库,能省不少事儿。
Next.js多语言静态生成完整配置流程
从解决构建报错到优化构建时间、处理翻译更新的完整步骤
⏱️ 预计耗时: 3 小时
- 1
步骤1: 解决构建报错:配置generateStaticParams
问题:Page "/en/about" is missing generateStaticParams()
解决:在layout.tsx中配置generateStaticParams
```tsx
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return [
{ locale: 'zh' },
{ locale: 'en' },
]
}
export default async function LocaleLayout({
children,
params: { locale }
}) {
// ...
}
```
关键点:
• generateStaticParams必须在layout或page中定义
• 参数名必须匹配[locale]
• 返回所有语言版本
注意:静态生成的页面不能使用cookies()、headers()等动态API - 2
步骤2: 优化构建时间
问题:6种语言×50个页面=300个页面,构建时间15分钟。
优化方法:
1. 选择性预渲染(只预渲染重要页面):
```tsx
export async function generateStaticParams() {
// 只预渲染首页和关于页
return [
{ locale: 'zh', slug: 'home' },
{ locale: 'zh', slug: 'about' },
{ locale: 'en', slug: 'home' },
{ locale: 'en', slug: 'about' },
]
}
```
2. 使用ISR(增量静态再生):
```tsx
export const revalidate = 3600 // 1小时后重新生成
```
3. 并行构建:
```tsx
export async function generateStaticParams() {
const locales = ['zh', 'en', 'ja', 'ko', 'fr', 'de']
const pages = ['home', 'about', 'contact']
return locales.flatMap(locale =>
pages.map(slug => ({ locale, slug }))
)
}
```
效果:从15分钟降到5分钟以内 - 3
步骤3: 解决翻译更新不生效
问题:更新了翻译文件,但网站还是显示旧翻译。
原因:Next.js会缓存导入的JSON文件。
解决方法:
1. 使用动态导入:
```tsx
const messages = await import(`../messages/${locale}.json`)
```
2. 清除缓存:
```bash
rm -rf .next
npm run build
```
3. 使用时间戳:
```tsx
const messages = await import(
`../messages/${locale}.json?v=${Date.now()}`
)
```
关键点:
• 开发时使用动态导入
• 生产环境清除缓存
• 使用时间戳避免缓存 - 4
步骤4: 避免动态API冲突
问题:静态生成的页面不能使用cookies()、headers()等动态API。
解决方法:
1. 检查页面类型:
```tsx
// ❌ 错误:静态页面使用动态API
export default async function Page() {
const cookies = await cookies() // 报错
return <div>...</div>
}
// ✅ 正确:动态页面使用动态API
export const dynamic = 'force-dynamic'
export default async function Page() {
const cookies = await cookies() // 可以
return <div>...</div>
}
```
2. 分离静态和动态页面:
```tsx
// 静态页面:不使用动态API
// 动态页面:使用dynamic = 'force-dynamic'
```
关键点:
• 静态页面不能使用动态API
• 需要动态API的页面标记为force-dynamic
• 合理分离静态和动态页面
常见问题
为什么App Router构建多语言网站会报错?
Pages Router:
• 在next.config.js中配置i18n字段
• 自动处理语言切换
• 自动生成所有语言版本
App Router:
• 不认next.config.js的i18n配置
• 需要使用generateStaticParams手动生成
• 参数名必须匹配[locale]
错误信息:
```
Error: Page "/en/about" is missing generateStaticParams()
so it cannot be used with output: "export".
```
解决方法:
```tsx
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return [
{ locale: 'zh' },
{ locale: 'en' },
]
}
```
关键点:generateStaticParams必须在layout或page中定义,参数名必须匹配[locale]。
如何优化多语言网站的构建时间?
优化方法:
1. 选择性预渲染(只预渲染重要页面):
```tsx
export async function generateStaticParams() {
// 只预渲染首页和关于页
return [
{ locale: 'zh', slug: 'home' },
{ locale: 'en', slug: 'home' },
]
}
```
2. 使用ISR(增量静态再生):
```tsx
export const revalidate = 3600 // 1小时后重新生成
```
3. 并行构建:
```tsx
export async function generateStaticParams() {
const locales = ['zh', 'en']
const pages = ['home', 'about']
return locales.flatMap(locale =>
pages.map(slug => ({ locale, slug }))
)
}
```
效果:从15分钟降到5分钟以内
建议:合理使用选择性预渲染和ISR,避免构建时间过长。
为什么翻译更新不生效?
问题:
• 更新了翻译文件
• 重新构建、重新部署
• 网站还是显示旧翻译
• 必须清除浏览器缓存才能看到新内容
解决方法:
1. 使用动态导入:
```tsx
const messages = await import(`../messages/${locale}.json`)
```
2. 清除缓存:
```bash
rm -rf .next
npm run build
```
3. 使用时间戳:
```tsx
const messages = await import(
`../messages/${locale}.json?v=${Date.now()}`
)
```
关键点:
• 开发时使用动态导入
• 生产环境清除缓存
• 使用时间戳避免缓存
建议:使用动态导入,避免缓存问题。
静态生成的页面能使用动态API吗?
错误示例:
```tsx
// ❌ 错误:静态页面使用动态API
export default async function Page() {
const cookies = await cookies() // 报错
return <div>...</div>
}
```
解决方法:
1. 标记为动态页面:
```tsx
export const dynamic = 'force-dynamic'
export default async function Page() {
const cookies = await cookies() // 可以
return <div>...</div>
}
```
2. 分离静态和动态页面:
```tsx
// 静态页面:不使用动态API
// 动态页面:使用dynamic = 'force-dynamic'
```
关键点:
• 静态页面不能使用动态API
• 需要动态API的页面标记为force-dynamic
• 合理分离静态和动态页面
注意:标记为force-dynamic的页面不会静态生成,每次请求都会重新渲染。
如何配置generateStaticParams?
代码示例:
```tsx
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return [
{ locale: 'zh' },
{ locale: 'en' },
]
}
export default async function LocaleLayout({
children,
params: { locale }
}) {
// ...
}
```
动态页面:
```tsx
// app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts()
const locales = ['zh', 'en']
return locales.flatMap(locale =>
posts.map(post => ({
locale,
slug: post.slug
}))
)
}
```
关键点:
• 参数名必须匹配动态路由([locale]、[slug])
• 返回所有可能的参数组合
• 静态生成的页面不能使用动态API
注意:如果参数组合很多,考虑使用选择性预渲染或ISR。
多语言网站的最佳实践是什么?
1. 使用generateStaticParams生成所有语言版本
2. 避免使用动态API(如果必须,标记为force-dynamic)
3. 合理使用选择性预渲染和ISR
4. 避免构建时间过长
翻译管理:
• 使用JSON文件管理翻译
• 支持嵌套结构
• 配合TypeScript实现类型安全
• 使用i18n Ally VSCode插件
性能优化:
• 选择性预渲染重要页面
• 使用ISR优化构建时间
• 避免全量预渲染
关键点:
• 理解App Router的构建机制
• 合理配置generateStaticParams
• 避免动态API冲突
• 优化构建时间
建议:如果手动实现太麻烦,试试next-intl这个库,能省不少事儿。
15 分钟阅读 · 发布于: 2025年12月25日 · 修改于: 2026年1月22日
相关文章
Next.js 电商实战:购物车与 Stripe 支付完整实现指南

Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战

Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 单元测试实战:Jest + React Testing Library 完整配置指南


评论
使用 GitHub 账号登录后即可评论