next-intl 国际化权威指南及使用说明
结论先行
next-intl 是 Next.js App Router 下官方推荐的国际化(i18n)方案,核心思路是「以 URL 为载体、以中间件为入口、以 React Context 为消息分发通道」。
语言切换失效(t("index") 不生效、根路径 404)几乎都源于两个缺位:
- 缺少
middleware.ts—— 路由层的语言检测与重写没有跑起来; layout.tsx没有注入NextIntlClientProvider/setRequestLocale—— 组件层拿不到消息上下文。
官方标准配置是固定五步(缺一不可),详见 next-intl 官方文档:
routing.ts→request.ts→navigation.ts→middleware.ts→[locale]/layout.tsx
只要这五步齐全,useTranslations 与 useLocale 即可在服务端/客户端组件中无缝工作。
问题背景
国际化最朴素的需求是:同一个页面,用户看中文,另一个用户看英文,URL 还要能区分、可分享。这就引出三个问题:
- 语言信息放在哪? URL 前缀(
/en、/zh)、Cookie、请求头Accept-Language都可以。next-intl 默认以 URL 前缀 为准,最直观、可收藏、利于 SEO。 - 谁来解析这个前缀? 页面是
app/[locale]/page.tsx这种带动态段的结构,[locale]必须有个值。把/映射成「默认语言」、把/en映射成「English」,这个翻译动作由 middleware 完成。 - 文案怎么下发到组件?
几十个命名空间(
Header、GlobalForm、KVS…)的 JSON 文案,通过 React Context(NextIntlClientProvider)注入,组件用useTranslations("Header")按命名空间取值。
很多项目「翻译文件写好了、t("index") 也写了,但就是不切」,往往卡在第 2、3
点——数据在,管道不通。
对小白友好的一句话:t() 只是「要一杯水」,但得有水管(middleware 决定走哪条路)和水龙头(layout 的
Provider 把水接到组件)。水桶(JSON 文案)是必要的,但不是充分的。
详细内容
一、整体架构:一条请求的完整旅程
浏览器请求 /en/xxx
│
▼
┌─────────────────┐ 检测 locale、按需重写 URL、必要时重定向
│ middleware.ts │ (createMiddleware(routing))
└────────┬────────┘
▼ 重写为 /[locale]/xxx,locale = "en"
┌─────────────────┐ 校验 locale、setRequestLocale、注入 Provider
│ [locale]/layout │ (NextIntlClientProvider 包住 children)
└────────┬────────┘
▼
┌─────────────────┐ 读取消息 JSON,按 locale 返回 messages
│ request.ts │ (getRequestConfig:requestLocale → messages)
└────────┬────────┘
▼
┌─────────────────┐ useTranslations("Header") / useLocale()
│ page.tsx 等 │ (服务端组件直接用;客户端组件经 Provider 用)
└─────────────────┘
五个文件各司其职,任何一个缺位,链路都会断。
二、标准配置五步
第 1 步:src/i18n/routing.ts —— 定义语言与 URL 策略
import {defineRouting} from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "zh"], // 支持的语言
defaultLocale: "zh", // 默认语言
localePrefix: "as-needed", // URL 前缀策略(关键配置)
});
localePrefix 决定「默认语言是否出现在 URL 里」,三种模式对比:
| localePrefix | 默认语言 zh 的 URL | 非默认语言 en 的 URL | 说明 |
|---|---|---|---|
always |
/zh |
/en |
所有语言都带前缀,最统一,利于 SEO |
as-needed |
/(不带前缀) |
/en |
默认语言省前缀,其余带,本项目采用 |
never |
/ |
/ |
完全靠 Cookie/请求头区分,URL 不可区分语言 |
⚠️as-needed下默认语言在 URL 中没有前缀,这要求 middleware 必须把裸路径/重写到[locale]段,否则根路径 404。这是最常见的翻车点。
第 2 步:src/i18n/request.ts —— 按 locale 加载消息
import {hasLocale} from "next-intl";
import {getRequestConfig} from "next-intl/server";
import {routing} from "@/i18n/routing";
export default getRequestConfig(async ({requestLocale}) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
requestLocale由setRequestLocale(locale)(见第 5 步)注入,是当前请求锁定的语言;hasLocale兜底:非法 locale 回落到defaultLocale。
第 3 步:src/i18n/navigation.ts —— 语言感知的路由 API
import {createNavigation} from "next-intl/navigation";
import {routing} from "@/i18n/routing";
export const {Link, redirect, usePathname, useRouter} = createNavigation(routing);
组件里不要用 next/link 和 next/navigation 的原生 API,改用这里导出的
Link / useRouter,它们会自动补上正确的 locale 前缀。
第 4 步:src/middleware.ts —— 入口闸门(最容易漏)
import createMiddleware from "next-intl/middleware";
import {routing} from "@/i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
};
它的三件事:
- 检测:根据 URL / Cookie /
Accept-Language判定当前 locale; - 重写:把 URL 映射到
[locale]动态段(/→/[locale],/en/xxx→[locale]=en); - 重定向:URL 缺失语言信息时补齐。
matcher 用来排除 _next 静态资源、api 路由、带扩展名的文件,避免误伤。
第 5 步:src/app/[locale]/layout.tsx —— 注入上下文(第二容易漏)
import {NextIntlClientProvider, hasLocale} from "next-intl";
import {setRequestLocale} from "next-intl/server";
import {notFound} from "next/navigation";
import {routing} from "@/i18n/routing";
export default async function RootLayout({children, params}) {
const {locale} = await params; // Next 15 中 params 是 Promise
if (!hasLocale(routing.locales, locale)) {
notFound(); // 非法 locale 直接 404
}
setRequestLocale(locale); // 供 request.ts 使用
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}
NextIntlClientProvider 不传 messages 时,会自动把 request 配置里的全部消息提供给客户端组件;服务端组件则直接经
context 取值。
三、本项目落地(multi-request 多消息分组)
本项目没有用标准单文件 request.ts,而是用 multi-request.ts
支持多消息分组,见 next.config.ts:
const withNextIntl = createNextIntlPlugin({
requestConfig: "./src/common/i18n/multi-request.ts",
});
其核心逻辑:读 messages/list.json 声明的分组列表,逐个加载并合并:
// messages/list.json
["default"]
// multi-request.ts 核心
const i18nList = (await import(`../../../messages/list.json`)).default;
let messages = {};
for (const key in i18nList) {
const path = i18nList[key];
const message = await import(`../../../messages/${path}/${locale}.json`);
if (path === "default") {
messages = {...message.default}; // 默认分组:打平到顶层
continue;
}
messages[path] = message.default; // 非默认分组:按分组名命名空间化
}
- 默认分组
default下的 JSON 内容打平到顶层,所以useTranslations("Header")直接取Header.index; - 非默认分组(如未来的
admin、im)会以messages[分组名]挂载,需用useTranslations("admin")访问; - 每个分组目录内保持
en.json/zh.json成对存在。
注意:src/common/i18n/request.ts是历史遗留的单文件版本(加载messages/${locale}.json,路径已失效),当前未被next.config.ts引用,属废弃代码,可清理。
四、常见问题与排查表
| 现象 | 根因 | 修复 |
|---|---|---|
根路径 / 返回 404 |
缺少 middleware,as-needed 下默认语言无前缀无法映射 [locale] |
补 src/middleware.ts |
报错 Unable to find next-intl context |
layout 没包 NextIntlClientProvider |
在 [locale]/layout.tsx 包 Provider |
| 切换语言不生效 / 文案始终是默认语言 | 未 setRequestLocale(locale),requestLocale 取不到值 |
layout 里调用 setRequestLocale |
| 非默认语言能访问、默认语言 404 | 前缀策略与 middleware 不匹配 | 确认 localePrefix 与 matcher 一致 |
t() 返回 key 本身或空 |
消息 JSON 路径/命名空间对不上 | 检查 messages/{分组}/{locale}.json 与 useTranslations 命名空间 |
| 客户端组件拿不到文案 | Provider 未覆盖到该组件 | 确认组件在 NextIntlClientProvider 树内 |
五、总结归纳
- next-intl 是「URL 前缀 + 中间件 + Context」三位一体的方案,五个文件(routing / request / navigation / middleware / layout)缺一不可。
- 语言切换失效,优先怀疑 middleware 和 layout——这两处是「管道」,翻译文件只是「水桶」。
localePrefix: "as-needed"让默认语言省掉前缀,代价是必须依赖 middleware 重写裸路径。- 本项目通过
multi-request.ts+messages/list.json实现了多消息分组加载,默认分组打平、其余分组命名空间化,可平滑扩展。 - 排错按「路由 → 上下文 → 消息」三层逐段排查,几乎能覆盖所有 i18n 失效场景。