这次给博客做国际化(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),导流规则按优先级执行:
访问无前缀 URL(如 / 或 /posts/abc)
│
├─ 1. 检查是否存在 NEXT_LOCALE cookie(用户曾手动切换过) → 按 cookie 指定语言跳转
├─ 2. 检查 Vercel Edge 国家头(x-vercel-ip-country === "CN") → 307 跳转至 /zh/...
└─ 3. 其他所有情况(海外访客 / 无法识别地区) → 307 跳转至 /en/...2. Cookie 仅在用户手动点击切换时写入
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。整体目录组织如下:
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 时,为了放行静态资源、后台和系统路由,我很自然地写了一段包含负向断言的长正则:
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 只做粗粒度匹配,放行逻辑交由内部函数处理。
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 还在跑,但缓存其实刷不动了
这个坑最为隐蔽。
博客后台具备评论审核功能:在管理后台点击“审核通过”后,文章前台需要刷新缓存显示新评论。旧代码一直跑得很正常:
revalidatePath(`/posts/${slug}`);迁移到多语言后,这段代码没有任何报错,后台点击审核也提示成功。但在前台刷新文章页,新评论死活不出来,数据库里数据明明已经更新了。
原因在于:路由改为 /[locale]/posts/[slug] 后,Next.js 内部派发的缓存 Tag 是由路由模式与实际请求路径共同生成的。原本的 /posts/${slug} 对应的路由模式在系统中已经不复存在,调用 revalidatePath("/posts/xxx") 相当于在对一个空地址做无效刷新,框架既不报错也不警告。
解法: 在需要按路径失效缓存的地方,必须循环遍历所有启用的语言版本:
for (const locale of routing.locales) {
revalidatePath(`/${locale}/posts/${result.postSlug}`);
}虽然正文是同一份中文,但在当前架构下,/en/posts/xxx 和 /zh/posts/xxx 是两个独立的静态渲染实例,两边的缓存都需要显式刷新。
坑四:页面级 generateMetadata 对 openGraph 是浅覆盖
全局根布局里通常会配置默认的 openGraph 属性(如 siteName、locale、默认预览图等)。
而在文章页的 generateMetadata 中,通常只需要为当前文章定制标题与摘要:
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 时,如果全局字段依然需要,必须显式补齐:
openGraph: {
type: "article",
title: post.title,
siteName: siteConfig.name,
locale: currentLocale,
// ...
}四、SEO 务实权衡:内容页 canonical 统一指向 /zh
常规的多语言 SEO 规范中,多语言站点应该为各个语言版本输出完整的 hreflang 标签矩阵。
但在“UI 双语、正文单语”的特殊模式下,如果对 /en/posts/xxx 声明了 hreflang="en",等于在向搜索引擎宣称“这是一篇英文文章”。搜索引擎爬虫抓取后进行文本分析,发现正文 95% 以上都是中文,很容易将其判定为欺诈或低质页面。
因此我采取了一个非常务实的 SEO 处理方案:
-
文章详情页(正文页):
- 无论访客通过
/en/posts/xxx还是/zh/posts/xxx访问,页面内的canonical统一固定指向/zh/posts/xxx。 - 文章页不输出任何 hreflang 声明,
sitemap.xml中也只收录/zh版本的文章链接。 - 明确告诉搜索引擎:正文只有中文版这一个权威来源,英文版本仅作为辅助访问入口,彻底规避重复内容竞争。
- 无论访客通过
-
纯 UI 聚合页(首页、归档列表、标签索引、关于页):
- 这些页面的主要信息均已完整翻译,属于正规的双语等价页面。
- 正常输出完整的三件套:
hreflang="en"、hreflang="zh-CN",并将x-default指向/en。
/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 翻译好,把路由和元数据理干净,即使正文还是中文,博客面对外部世界时也显得从容、专业得多。至于正文的多语言化——等以后真正有充裕的时间与精力,再慢慢提上日程吧。
