Next.js多语言实践:next-intl、hreflang 与 RTL 的经验#
我运营的三个生产站点在主题上毫无关联——一个开发者作品集、一个跑者用的配速计算器,以及一个关于天气对健康影响的服务——但它们的 i18n 架构完全一致。dodecaidr.pro 支持五种语言(ru、en、es、ja、zh-CN),runcalculator.pro 同样是五种,meteohealth.pro 则支持六种,其中包括需要 RTL 布局的阿拉伯语。三者都构建在 Next.js App Router 与 next-intl 之上,也都踩过同样的坑:损坏的 hreflang、在语言完整性测试中悄悄消失的翻译,以及在阿拉伯语下彻底崩坏的布局。
这篇文章不是从零开始的 next-intl 教程,而是我在三个项目中反复使用的具体架构决策的梳理,代码来自 runcalculator.pro 和 meteohealth.pro,并附上我自己踩过、或刻意避开的坑。
三个生产项目,同一种头疼#
当一个网站只有一种语言时,i18n 只是一个翻译字符串库。而当语言数量超过五种,i18n 就变成了一项架构决策,牵涉路由、SEO、内容测试和布局。在三个项目里,我都收敛到了同一套模式:
localePrefix: 'always'—— 每个 URL 都保留语言前缀(/ru/...、/en/...),默认语言也不例外,不存在"裸"路径。这简化了缓存、日志和 hreflang 的处理,但要求显式设置x-default。- 在渲染前解析语言的 middleware / proxy —— next-intl 在页面开始组装之前,就已经根据 URL、Cookie 和
Accept-Language确定了语言。 - 由单一来源生成的 hreflang 与
x-default—— 不是在每个页面手写,而是由一个了解全部语言列表的函数统一生成。 - 本地化内容放在并行文件夹中 —— 采用
content/<type>/<locale>/...结构,而不是动辄数万行的 JSON 词典。 - 语言完整性测试 —— 一旦新文章缺少五到六个语言版本中的任何一个,CI 就会失败的脚本。
- RTL 布局验证 —— 并非三个项目都需要,但只要存在阿拉伯语(meteohealth.pro),就无法回避。
接下来看看这几点在代码中具体是什么样子。
唯一可信来源:语言配置#
我在别人的项目里常见的第一个错误(自己也犯过一次)是:语言列表在 middleware、next-intl 配置和语言切换组件里各存一份。添加一个新语言时,它出现在切换器里,却在 middleware 里不生效——因为那里是另一个数组。
解决办法是用一个文件,让其他所有地方都从这里导入:
// lib/i18n/config.ts
export const locales = ['ru', 'en', 'es', 'ja', 'zh-CN'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'ru'
export const i18nConfig = {
locales,
defaultLocale,
localePrefix: 'always' as const, // every URL keeps its locale prefix
}
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale)
}
export function getValidLocale(locale: string | undefined): Locale {
if (!locale) return defaultLocale
return isValidLocale(locale) ? locale : defaultLocale
}middleware、sitemap 生成函数、hreflang 生成函数,以及 MDX 内容加载器,都从这里导入 locales 和 defaultLocale。新增一个语言,只需要修改这一个数组,再补上翻译文件和内容文件——不需要手动改动任何路由逻辑。
next-intl v4:requestLocale 与消息加载#
在 next-intl v4 中,传给 getRequestConfig 的语言不再是同步值,而是以 Promise 的形式到达——如果代码没有检查类型,这个变化会在从 v3 升级时导致部分代码出错。当前的标准写法如下:
// i18n.ts
import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { locales, defaultLocale, isValidLocale, getValidLocale, type Locale } from './lib/i18n/config'
export default getRequestConfig(async ({ requestLocale }) => {
// next-intl v4: locale arrives as a Promise
const requested = await requestLocale
if (requested && !isValidLocale(requested)) {
notFound()
}
const locale = getValidLocale(requested)
const [common, marketing] = await Promise.all([
import(`./locales/${locale}/common.json`),
import(`./locales/${locale}/marketing.json`),
])
return {
locale,
messages: {
common: common.default,
marketing: marketing.default,
},
timeZone: getTimeZone(locale),
}
})
function getTimeZone(locale: Locale): string {
const timeZones: Record<Locale, string> = {
ru: 'Europe/Moscow',
en: 'America/New_York',
es: 'Europe/Madrid',
ja: 'Asia/Tokyo',
'zh-CN': 'Asia/Shanghai',
}
return timeZones[locale] ?? 'UTC'
}有两个教程里很少提到的细节。第一,只有当语言被传入且无效时,才应该调用 notFound()——而不是在语言缺失时调用,否则任何语言尚未解析完成的路径都会崩溃。第二,时区不是装饰性的细节:如果不设置它,Intl.DateTimeFormat 和 next-intl 的日期格式化函数会回退到服务器所在时区,网站日语版本上文章的发布日期就可能显示成前一天。
proxy.ts 取代 middleware.ts:Next.js 16 的变化#
Next.js 16 把 middleware.ts 重命名为 proxy.ts——文件名和导出函数的名字变了,但与 next-intl 的约定没有变:next-intl/middleware 中的 createMiddleware 仍然被包装进同一个处理函数中。
// proxy.ts — renamed from middleware.ts in Next.js 16
import createMiddleware from 'next-intl/middleware'
import { NextRequest } from 'next/server'
import { locales, defaultLocale } from './lib/i18n/config'
const intlMiddleware = createMiddleware({
locales,
defaultLocale,
localePrefix: 'always',
})
export default function proxy(request: NextRequest) {
return intlMiddleware(request)
}
export const config = {
// skip /api, /_next and static files with a dot in the path
matcher: ['/((?!api|_next|.*\\..*).*)'],
}实践中,这个文件也是添加端到端请求日志(方法、路径、状态码、耗时)的好位置——只要在调用 intlMiddleware(request) 之后而不是取代它,就不会干扰 i18n 的重定向逻辑。
hreflang 与 x-default:75% 的网站在这里翻车#
有统计数据(Ahrefs)显示,大约 75% 拥有国际化版本的网站在 hreflang 上出错:缺少回链(A 页面链接到 B,但反过来没有)、标签里的 URL 损坏,以及最常见的一种——所有语言版本的 canonical 都指向英文页面。最后这种错误实际上是在告诉搜索引擎"忽略其他语言版本",也正是网站所有者亲手抵消了自己在 i18n 上的投入。
为了不在每个页面手写 hreflang,我用一个函数统一生成,并同时在 generateMetadata 和 sitemap 中复用:
// Generates alternates for both metadata and sitemap entries
function generateAlternates(route: string) {
const alternates: Record<string, string> = {}
for (const locale of locales) {
alternates[locale] = route
? `${baseUrl}/${locale}/${route}`
: `${baseUrl}/${locale}`
}
// With localePrefix: 'always' there is no locale-neutral URL,
// so x-default has to point somewhere — the default locale is the pragmatic choice
alternates['x-default'] = route
? `${baseUrl}/${defaultLocale}/${route}`
: `${baseUrl}/${defaultLocale}`
return alternates
}
// app/[locale]/articles/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params
return {
alternates: {
canonical: `${baseUrl}/${locale}/articles/${slug}`,
languages: generateAlternates(`articles/${slug}`),
},
}
}一个容易被忽略的细节:x-default 应该只出现一次,指向语言不匹配任何已有语言的用户应该看到的页面——通常是首页或语言选择页。在 localePrefix: 'always' 的设定下,根本不存在语言中立的 URL,所以 x-default 只能指向默认语言,这是一种务实的折中,而不是"标准答案"——最好在代码里明确写清楚这一点,免得半年后有人把它"修正"成一个并不存在的根路径。
每个页面还必须引用自身(自引用 hreflang)——如果没有这一点,搜索引擎完全有理由忽略该页面上的整组 alternate 标签。
并行文件夹中的 MDX 内容,配合 Zod 校验与语言完整性测试#
用于界面翻译的 JSON 词典,对按钮和标签来说是合理的方案,但并不适合文章、项目描述、隐私政策这类内容。把长文本存进 JSON,意味着丢失格式、代码块,以及"直接打开文件在编辑器里写作"这种最朴素的体验。所以内容按语言分别存放在并行文件夹中:
content/articles/
├── ru/general/nextjs-i18n-next-intl.mdx
├── en/general/nextjs-i18n-next-intl.mdx
├── es/general/nextjs-i18n-next-intl.mdx
├── ja/general/nextjs-i18n-next-intl.mdx
└── zh-CN/general/nextjs-i18n-next-intl.mdx每个文件的 frontmatter 在读取时都会通过 Zod schema 校验:必填字段、允许的分类取值,以及 YYYY-MM-DD 格式的日期——如果译者(或者深夜 11 点的我自己)漏填了字段,或者把分类名拼错了,构建会因为清晰的 Zod 报错而失败,而不是在生产环境里悄悄展示一张空白的文章卡片。
另有一个独立的完整性测试脚本,会检查每个 slug 是否在所有语言下都有对应文件,并核对 JSON 翻译词典的结构(键名而非值)在各语言之间是否一致。没有这个测试,典型的失败场景是这样的:文章用俄语发布,PR 合并了,一个月后才发现网站的日文版本仍然显示 404——或者更糟,显示的是取自共享默认值的旧标题。
RTL 不只是 dir="rtl":来自 meteohealth.pro 的经验#
三个项目里只有 meteohealth.pro 支持阿拉伯语,而正是在那里,出现了 ru/en/es/ja/zh-CN 五种语言中从未出现过的 RTL 特有 bug——这五种语言都不会镜像布局。给 <html> 加上 dir="rtl" 大约能完成 60% 的工作:文字会正确对齐,并按正确方向流动。剩下的 40% 是:图标需要镜像翻转(一个指向"前进"的箭头不能反过来指向后方),输入框需要接受阿拉伯数字和文本、而不是按字符逐个切换方向,以及那些基于 margin-left/padding-right 构建的布局——在 RTL 下会直接跑到错误的一侧。
// app/[locale]/layout.tsx
import { locales, type Locale } from '@/lib/i18n/config'
const RTL_LOCALES: Locale[] = ['ar'] // meteohealth.pro only
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: Locale }>
}) {
const { locale } = await params
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr'
return (
<html lang={locale} dir={dir}>
<body className="ms-0 pe-4 rtl:pe-0 rtl:ps-4">
{children}
</body>
</html>
)
}以下这些实践结论,适用于任何包含 RTL 语言的项目:
- 使用逻辑 CSS 属性(
margin-inline-start、padding-inline-end;在 Tailwind 中是ms-/ps-/me-/pe-),而不是物理属性(margin-left/padding-right)——这样镜像会自动发生,不需要再用rtl:重复写一遍类名。 - 对于逻辑属性覆盖不到的情况(图标、特定的变换),使用 Tailwind 的
rtl:修饰符,比如箭头用rtl:rotate-180。 - 混合内容——阿拉伯语文本中夹杂拉丁字母(品牌名、邮箱、代码)——最好用
<bdi>包裹,否则在方向边界处字符顺序可能会在视觉上错乱。 - RTL 不能靠上线前"看一眼"来验证。对关键界面(卡片、表单、导航)在
ltr和rtl两个版本下做截图对比,能捕捉到那些原本只会在用户反馈里才暴露出来的回归问题。 - 测试环境从第一天起就该用真实的阿拉伯语文本,而不是"翻译以后再补"。伪镜像的拉丁文字上出现的 RTL bug,和真实阿拉伯文字上出现的 RTL bug,是两类完全不同的问题。
三个项目的对比与检查清单#
| 项目 | 语言数 | RTL | 内容 | 特点 |
|---|---|---|---|---|
| dodecaidr.pro | 5(ru、en、es、ja、zh-CN) | 无 | MDX + Zod schema,CI 中的语言完整性测试 | 作品集与文章被当作内容,而不是界面字符串 |
| runcalculator.pro | 5 | 无 | MDX 描述文本与本地化的计算公式 | 数字格式(配速、距离)和文本一样依赖于语言,而不只是文字本身 |
| meteohealth.pro | 6(+阿拉伯语) | 有 | MDX 加上本地化截图,以及 5 种以上语言的政策文档 | 唯一一个 RTL 不是理论、而是每天都要检查布局的项目 |
如果要简要总结哪些东西是在项目之间原封不动继承下来的:
- 语言列表和默认语言只存在于一个文件里,而不是分散在三处。
- middleware /
proxy.ts在渲染前解析语言,不会在 next-intl 之上自作主张地做额外判断。 - hreflang 和
x-default由函数统一生成,而不是逐页手工复制。 - 内容按语言存放在 MDX 中,frontmatter 经过 Zod 校验,并在 CI 中跑语言完整性测试。
- RTL 不是一次性改个
dir就完事——而是逻辑 CSS 属性,加上一套专门的测试流程,只要语言列表里出现阿拉伯语或希伯来语就必须执行。
写在纸面上,这五条听起来都像是常识。但实际情况是,在这三个项目里,每一条都至少被违反过一次,才最终被固定成一条规则。



