切换语言
切换主题

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 32s

15 分钟!你没看错。每次改点小东西都要等这么久,开发体验简直崩溃。我当时就在想:“这要是上了生产环境,CI/CD 不得等到天荒地老?“

场景三:翻译更新不生效

最让人抓狂的是这个:我更新了 zh-CN.json 翻译文件,重新构建、重新部署,结果网站上还是显示旧的翻译!必须清除浏览器缓存才能看到新内容。这在生产环境简直是灾难,用户看到的都是过期内容。

问题的根源在哪?

后来我花了不少时间研究,才搞明白这些问题的本质:

  1. App Router 不再支持 Pages Router 的 i18n 配置 - 这是最大的坑。你在 next.config.js 里配置 i18n 字段,App Router 根本不认。

  2. 静态导出与动态渲染的冲突 - 当你设置 output: 'export' 时,Next.js 要求所有页面必须在构建时确定。如果你用了 cookies()headers() 这些动态 API,就会报错。

  3. 翻译文件的缓存机制 - Next.js 会缓存导入的 JSON 文件,开发时更新了翻译,但缓存没失效,所以看不到最新内容。

如果你也遇到过这些问题,那这篇文章就是为你准备的。接下来我会一步步教你怎么正确实现 Next.js App Router 的多语言静态生成,并且避免这些坑。

理解 App Router 的 i18n 新范式

在开始写代码之前,我觉得有必要先理解一下 App Router 的国际化思路。这跟 Pages Router 真的很不一样。

Pages Router vs App Router:天差地别的两种方案

我做了个对比表,你可以直观感受一下区别:

特性Pages RouterApp Router
配置方式next.config.jsi18n 字段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.tsxpage.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             # 语言检测和重定向

为什么这样设计?

  1. [lang] 文件夹:这是动态路由的核心,Next.js 会把 URL 中的语言参数传递给页面组件。
  2. 按命名空间划分翻译:避免一个翻译文件太大,按页面功能分开,按需加载。
  3. 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]

关键点解释

  1. as const:这是 TypeScript 的写法,确保类型是精确的字面量类型,而不是宽泛的 string[]
  2. localesToPrerender:这个很重要!如果你支持 10 种语言,但只预渲染 2 种主要语言,构建时间能减少 80%。其他语言可以通过 ISR(增量静态再生)或按需生成。
  3. 命名空间:把翻译文件拆分成多个 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
  }
}

这个工具的亮点

  1. 缓存机制:第一次加载后就缓存起来,避免重复读文件。
  2. 错误处理:翻译文件找不到时不会崩溃,只是警告并返回空对象。
  3. 变量替换:支持在翻译中使用 {{变量名}} 占位符。
  4. 类型友好:配合 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}}"
}

翻译文件的最佳实践

  1. 分层结构:用嵌套对象组织翻译,不要把所有键都放在顶层。
  2. 变量占位符:使用 {{变量名}} 格式,便于统一处理。
  3. 保持键名一致:所有语言的翻译文件应该有相同的键结构。
  4. 添加注释:在复杂的翻译旁边添加注释,说明使用场景。

第六步:处理嵌套动态路由

如果你的项目有博客或产品详情页,就需要处理嵌套的动态路由。这是我踩过最大的坑之一。

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

  1. 检查 Cookie 中是否有保存的语言偏好(比如用户上次选择了中文)
  2. 如果没有,检查浏览器的 Accept-Language 头(浏览器会自动发送用户的系统语言)
  3. 根据检测结果,重定向到 https://example.com/zh-CN/bloghttps://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

这样配置后:

  1. 构建时只生成 2 种语言 × 10 篇文章 = 20 个页面
  2. 用户访问未预渲染的页面时,Next.js 会实时生成并缓存
  3. 缓存 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"`.

排查步骤

  1. ✅ 检查是否在 layout.tsxpage.tsx 中定义了 generateStaticParams
  2. ✅ 确认函数名拼写正确(不是 getStaticParams,不是 generateParams
  3. ✅ 确认函数正确导出(必须是 export async function
  4. ✅ 检查参数名是否与路由文件夹名匹配
// ❌ 错误示例
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'

排查清单

  1. ✅ 检查文件路径是否正确(注意大小写,Linux 区分大小写)
  2. ✅ 确认 JSON 文件语法正确(可以用在线工具验证)
  3. ✅ 检查 tsconfig.json 的路径别名配置:
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"]
    }
  }
}
  1. ✅ 确认翻译文件在构建时被正确包含:
// 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 多语言静态生成方案,包括:

  1. 核心功能

    • 基于 [lang] 动态路由的多语言结构
    • 使用 generateStaticParams 生成静态页面
    • 按命名空间划分的翻译文件系统
    • Middleware 自动语言检测和重定向
  2. 性能优化

    • 选择性预渲染主要语言(减少 75% 构建时间)
    • 使用 ISR 按需生成次要语言
    • 并行数据获取
    • 开发环境禁用缓存
  3. 问题解决

    • 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

    步骤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

    步骤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

    步骤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

    步骤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构建多语言网站会报错?
原因:App Router不再支持Pages Router的i18n配置。

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]。
如何优化多语言网站的构建时间?
问题:6种语言×50个页面=300个页面,构建时间15分钟。

优化方法:

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,避免构建时间过长。
为什么翻译更新不生效?
原因: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()}`
)
```

关键点:
• 开发时使用动态导入
• 生产环境清除缓存
• 使用时间戳避免缓存

建议:使用动态导入,避免缓存问题。
静态生成的页面能使用动态API吗?
不能。静态生成的页面不能使用cookies()、headers()等动态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?
配置位置:在layout.tsx或page.tsx中定义

代码示例:
```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日

评论

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

相关文章