JIANG RUIJIAN
Back to all posts

给 Next.js 博客做中英双语:next-intl 改造与四个「静默翻车」踩坑记

0 views

这次给博客做国际化(i18n)改造,策略上比较务实:一期先把前台 UI 界面区分为中英文双语,长篇技术文章的内容多语言化放在后续阶段再考虑。

UI 的双语改造涉及导航栏、页脚、搜索弹窗、空状态、交互按钮与报错提示,词条总共两百条左右。技术上选用的整体方案是:

  • 核心技术栈:基于 Next.js 16 (App Router) 搭配 next-intl (4.x)。
  • 路由架构:采用显式双前缀路由 src/app/[locale]/...,/zh 与 /en 均为平等的真实路径,避免单前缀隐式路由带来的语义歧义。
  • 布局隔离:利用 Next.js 的多 Root Layout 特性,前台 [locale]/layout.tsx 动态管理 lang 属性与多语言 Provider,后台 (admin)/layout.tsx 保持纯中文独立运行,互不污染。
  • 边缘导流:通过 Next.js 16 的 proxy.ts(原 middleware.ts)在边缘节点处理无前缀请求。默认重定向至英文站,对中国大陆 IP 自动导流至 /zh,并优先尊重用户在界面上手动切换的 Cookie 偏好。

整体方案的骨架很清晰,但在实际开发和构建中,踩到了几个让人头疼的暗坑。这些坑最恶心的地方在于:它们完全不报错,控制台干干净净没有任何异常,框架只是静默地不按预期工作。

这篇文章把这套方案的具体实现细节,以及排查这几个静默坑的过程完整记录下来。


一、动手前的几个克制决定

写代码之前,有三个设计层面的取舍。它们看似是小策略,但直接决定了后续的代码量和维护负担。

1. 显式双路径 + 边缘智能导流

许多多语言教程喜欢推荐“默认语言不带前缀”的模式(例如中文在 /posts/xxx,英文在 /en/posts/xxx)。但在实践中,这种单前缀模式很容易引起混乱:无前缀的路径到底代表默认语言,还是未经处理的原始路由?在 CDN 缓存、日志排查和搜索引擎爬取时往往会埋下一堆隐患。

因此我选择了显式双前缀:所有前台页面都严格带有 /[locale]/ 前缀,/zh/... 和 /en/... 都是真实平等的路由,URL 的语言语义完全确定。

对于没有前缀的直接访问(根路径 / 或是之前分享出去的历史链接 /posts/xxx),导流规则按优先级执行:

TEXT
访问无前缀 URL(如 / 或 /posts/abc)
│
├─ 1. 检查是否存在 NEXT_LOCALE cookie(用户曾手动切换过) → 按 cookie 指定语言跳转
├─ 2. 检查 Vercel Edge 国家头(x-vercel-ip-country === "CN") → 307 跳转至 /zh/...
└─ 3. 其他所有情况(海外访客 / 无法识别地区)                → 307 跳转至 /en/...

next-intl 默认会在每次访问带有语言前缀的页面时,自动写入或刷新语言 Cookie。官方视其为便利特性,但在实际分享场景下体验很糟糕。

举个例子:你把一篇中文文章 /zh/posts/xxx 链接分享给海外同事,他顺手点开看一眼。如果框架自动写入 Cookie,他的浏览器就会被悄悄种下 NEXT_LOCALE=zh。下次他直接访问你的博客首页时,就会被强行拽进中文站。他只是偶然看了一篇特定的文章,这并不代表他的日常偏好是中文。

所以我关掉了 next-intl 的自动写入(localeCookie: false),全站只留一个写入入口:用户在界面上主动点击语言切换按钮。这样 Cookie 的语义非常纯粹,只代表“用户的显式偏好”,而不是“用户上次路过哪种语言”。

3. 旧链接统一走 307,不折腾 308

教科书上的 SEO 迁移规范会建议:旧路由变动应该用 308 永久重定向,把外链权重直接传递给新地址。

这听起来很专业,但对于个人博客来说,存量文章本来就少,也没有庞大的外部反向链接资产,为旧链接专门在中间件里维护复杂的正则和 308 规则属于过度设计。直接使用统一的 307 临时重定向(加上 Cache-Control: private, no-store 防止 CDN 缓存错误目标),重复内容和权重归属直接交给 HTML 里的 canonical 标签仲裁,代理层的逻辑简单干净得多。


二、骨架搭建:双 Root Layout 与 Next 16 Proxy

技术栈是 next-intl 搭配 Next.js 16。整体目录组织如下:

TEXT
src/app/
├── [locale]/              # 前台页面,/en 与 /zh 均由此渲染
│   ├── (blog)/            # 首页、文章、归档、标签、关于
│   ├── layout.tsx         # 前台 Root Layout:<html lang={locale}> 动态注入
│   └── posts/[slug]/      # 文章详情
├── (admin)/               # 管理后台与登录页,纯中文,不参与国际化
│   ├── admin/
│   └── layout.tsx         # 后台 Root Layout:<html lang="zh-CN"> 写死
├── feed.xml / robots.ts / sitemap.ts / icon.tsx   # 站点级路由,独立于 locale

多 Root Layout 实践

很多人以为 Next.js App Router 必须保留全局唯一的 src/app/layout.tsx。实际上,Next 允许在顶层拆分多个平行的 Root Layout。

直接删掉全局的 app/layout.tsx,将 [locale]/layout.tsx 和 (admin)/layout.tsx 分别作为独立的根布局。这样前台可以根据路由动态改变 <html lang="..."> 并挂载 NextIntlClientProvider,而后台 Admin 保持原本干净的结构,不需要引入任何国际化 Provider,也不会被前台的语言逻辑污染。

Next 16 Proxy(前 Middleware)

Next.js 16 中规范了 proxy.ts(替代原本的 middleware.ts)。在 proxy 中,我们只对 /admin 路径做轻量的 Auth.js 乐观拦截,避免让前台所有普通请求都被迫执行一次 session 查询带来额外开销。


三、踩坑实录:四个“不报错”的静默陷阱

架构定好之后就是编码实施,而真正的考验全在这一步。以下四个坑,没有一个会在终端抛出红字错误,每个都需要结合行为反推原因。

坑一:matcher 正则被静默丢弃,proxy 完全装死

在配置 proxy.ts 的 matcher 时,为了放行静态资源、后台和系统路由,我很自然地写了一段包含负向断言的长正则:

TypeScript
export const config = {
  matcher: [
    "/((?!admin(?:/|$)|api(?:/|$)|feed\\.xml$|...).*)",
  ],
};

写完本地启动测试,发现 proxy 完全不执行,任何路由都直接穿透,就像这个文件根本不存在一样。控制台没有任何报错,也没有任何编译警告。

排查了很久才发现:Next.js 的 matcher 并不是原生 RegExp,而是通过 path-to-regexp 编译的路由模板。如果正则中包含复杂的嵌套非捕获分组(如 (?:/|$))或高级语法,path-to-regexp 解析失败后,Next.js 不会抛出异常,而是直接把这条 matcher 规则静默忽略。

要验证这一点,可以在 build 之后查看 .next/server/functions-config-manifest.json,如果里面的 matcher 数组是空的或者缺少了项,就说明规则被吞了。

解法:matcher 只做粗粒度匹配,放行逻辑交由内部函数处理。

TypeScript
export const config = {
  matcher: [
    "/admin/:path*",                              // 后台鉴权
    "/((?!_next/|_vercel/|api/|auth/|admin/).*)", // 极简前缀排除
  ],
};
 
// 系统路由与常见静态文件,在 proxy 函数体内部用原生正则做白名单放行
const SYSTEM_PATH = /^\/(?:feed\.xml|robots\.txt|sitemap\.xml|favicon\.ico|icon|apple-icon)$/;
const STATIC_EXTENSION = /\.(?:ico|png|jpe?g|gif|svg|webp|avif|css|js|woff2?|ttf|pdf|zip)$/i;
 
function isSystemRequest(pathname: string) {
  return SYSTEM_PATH.test(pathname) || STATIC_EXTENSION.test(pathname);
}

框架帮你做规则编译的地方,往往也是容易把错误吞掉的地方。把过滤逻辑收拢在自己的函数里,看得清测得到,反而踏实。

坑二:Route Group 里的 OG 图片路由自动追加哈希,导致 404

前台文章页放在 (blog) 路由组内,文章对应的动态 OpenGraph 卡片生成文件 opengraph-image.tsx 原本也顺理成章地放在组内。

然而部署测试时发现,社交平台抓取分享卡片全部失败,卡片变成了灰块。去翻构建产物才发现,Next.js 生成的 OG 图片路由变成了:

/[locale]/posts/[slug]/opengraph-image-kyzzey

Next.js 在处理路由组内的 metadata 路由时,为了防止潜在命名冲突,自动在文件路径末尾附加了一串六位哈希。而页面代码与社交爬虫默认请求的都是标准路径 .../opengraph-image,结果必然是 404。

解法: 将 opengraph-image.tsx 移出 (blog) 路由组,放到 src/app/[locale]/posts/[slug]/ 目录下,与页面路由参数平行。这样生成的图片路由就会恢复成无哈希的标准地址。

同时需要注意,移出路由组后两者不再位于同一文件层级,Next.js 不再提供隐式回退绑定,必须在文章页的 generateMetadata 中显式传入 openGraph.images。

坑三:后台 revalidatePath 还在跑,但缓存其实刷不动了

这个坑最为隐蔽。

博客后台具备评论审核功能:在管理后台点击“审核通过”后,文章前台需要刷新缓存显示新评论。旧代码一直跑得很正常:

TypeScript
revalidatePath(`/posts/${slug}`);

迁移到多语言后,这段代码没有任何报错,后台点击审核也提示成功。但在前台刷新文章页,新评论死活不出来,数据库里数据明明已经更新了。

原因在于:路由改为 /[locale]/posts/[slug] 后,Next.js 内部派发的缓存 Tag 是由路由模式与实际请求路径共同生成的。原本的 /posts/${slug} 对应的路由模式在系统中已经不复存在,调用 revalidatePath("/posts/xxx") 相当于在对一个空地址做无效刷新,框架既不报错也不警告。

解法: 在需要按路径失效缓存的地方,必须循环遍历所有启用的语言版本:

TypeScript
for (const locale of routing.locales) {
  revalidatePath(`/${locale}/posts/${result.postSlug}`);
}

虽然正文是同一份中文,但在当前架构下,/en/posts/xxx 和 /zh/posts/xxx 是两个独立的静态渲染实例,两边的缓存都需要显式刷新。

坑四:页面级 generateMetadata 对 openGraph 是浅覆盖

全局根布局里通常会配置默认的 openGraph 属性(如 siteName、locale、默认预览图等)。

而在文章页的 generateMetadata 中,通常只需要为当前文章定制标题与摘要:

TypeScript
return {
  title: post.title,
  openGraph: {
    type: "article",
    title: post.title,
    publishedTime: toIsoString(post.createdAt),
  },
};

检查线上输出的 <head> 时才发现,文章页里的 og:site_name 和 og:locale 全部不见了。

原因是 Next.js 对 metadata 的合并逻辑是浅层覆盖(Shallow Merge):一旦子页面定义了 openGraph 字段,它就会整体替换掉根布局里的整个 openGraph 对象,而不是将字段深层合并进去。

因此在任何子页面中覆盖 openGraph 时,如果全局字段依然需要,必须显式补齐:

TypeScript
openGraph: {
  type: "article",
  title: post.title,
  siteName: siteConfig.name,
  locale: currentLocale,
  // ...
}

四、SEO 务实权衡:内容页 canonical 统一指向 /zh

常规的多语言 SEO 规范中,多语言站点应该为各个语言版本输出完整的 hreflang 标签矩阵。

但在“UI 双语、正文单语”的特殊模式下,如果对 /en/posts/xxx 声明了 hreflang="en",等于在向搜索引擎宣称“这是一篇英文文章”。搜索引擎爬虫抓取后进行文本分析,发现正文 95% 以上都是中文,很容易将其判定为欺诈或低质页面。

因此我采取了一个非常务实的 SEO 处理方案:

  1. 文章详情页(正文页):

    • 无论访客通过 /en/posts/xxx 还是 /zh/posts/xxx 访问,页面内的 canonical 统一固定指向 /zh/posts/xxx。
    • 文章页不输出任何 hreflang 声明,sitemap.xml 中也只收录 /zh 版本的文章链接。
    • 明确告诉搜索引擎:正文只有中文版这一个权威来源,英文版本仅作为辅助访问入口,彻底规避重复内容竞争。
  2. 纯 UI 聚合页(首页、归档列表、标签索引、关于页):

    • 这些页面的主要信息均已完整翻译,属于正规的双语等价页面。
    • 正常输出完整的三件套:hreflang="en"、hreflang="zh-CN",并将 x-default 指向 /en。
TEXT
/en/posts/xxx  ──>  canonical 指向 /zh/posts/xxx (不声明 hreflang,sitemap 不收录)
/zh/posts/xxx  ──>  canonical 指向自身             (权威收录页)
 
/en/about      ──>  canonical 指向自身             (输出完整 hreflang: en / zh-CN / x-default)
/zh/about      ──>  canonical 指向自身             (输出完整 hreflang: en / zh-CN / x-default)

不要对搜索引擎撒谎。译文还没做出来,就不要在机器可读标签里宣称它存在。


五、容易漏掉的工程细节

除了上面几个大坑,还有几个细节处理起来非常细碎,但对日常稳定性和体验很有帮助:

  • 支持带点号的 Slug: 如果文章 slug 允许包含版本号(如 next.js-16),在 proxy 区分静态文件时,绝对不能图省事用包含点号(pathname.includes("."))作为判断依据,必须严格匹配已知的文件扩展名后缀,否则这类文章路由会被误判为静态资源。
  • 英文复数处理(ICU Plural): 中文里写「共 {count} 篇文章」无论数量多少都不会语病,但英文直译 "{count} posts" 在数量为 1 时就会变成语病 "1 posts"。next-intl 原生支持 ICU 语法,直接在 JSON 里配置复数规则:
    JSON
    "count": "{count, plural, one {# article} other {# articles}}"
  • 日期随界面语言本地化: 同一篇发布于 2026 年 10 月 7 日的文章,中文界面显示「2026年10月7日」,英文界面格式化为「Oct 7, 2026」。搜索结果、海报和文章详情统一根据当前的 locale 传参格式化。
  • 保留原生的英文技术标签: Markdown 渲染中的 Callout 提示框(Note、Tip、Warning、Caution)中英文界面下均保留英文原样。这是 GitHub 风格的固定标识,强行翻译成中文反而违和。
  • 隐私合规坦诚声明: 引入了语言 Cookie,并在边缘读取了客户端的 x-vercel-ip-country 请求头。我的做法是将国家码只在内存中用于一次性的 307 分流,不落库、不记日志,并在隐私政策弹窗中如实说明。

结语

整套改造落地后,我用脚本把全站所有核心路由走了一遍端到端回归:20 多个页面的 HTTP 状态码、不同 IP 头部的重定向表现、中英文界面切换、搜索、评论与分享卡片,挨个核验了一遍。

在现代 Web 框架中,抽象层越来越多。报错的 Bug 往往是好修的,因为堆栈信息已经把原因和位置写在脸上;最需要提防的恰恰是那些无声无息偏离预期的静默行为——正则编译失败不吭声、缓存 Tag 失效不吭声、元数据覆盖不吭声。

把 UI 翻译好,把路由和元数据理干净,即使正文还是中文,博客面对外部世界时也显得从容、专业得多。至于正文的多语言化——等以后真正有充裕的时间与精力,再慢慢提上日程吧。