next-intl 进阶用法与多语言 Key 类型约束
如果一个 Next.js 项目只是“能切语言”,那通常只需要把文案放进消息文件里,再在组件里调用 t('xxx')。
但项目一旦重新把 next-intl 当成正式方案来用,关注点很快就会变:
- 不只是翻译字符串,还要处理路由、服务端取词、Metadata、多语言 SEO
- 不只是
t('title'),还会用到t.rich(...)、t.raw(...)、ICU 插值、select、复数、日期和数字格式化 - 不只是“有没有文案”,还会开始关心 key 是否写错、参数是否漏传、locale 是否受控
- 再往后,团队往往会想把部分业务配置也绑到类型系统上,比如给“允许传入的多语言 key”再包一层业务类型
这篇就专门讲这一层。
为什么现在很多 App Router 项目会重新选 next-intl
如果你已经在用 App Router,多语言方案最怕的通常是两件事:
- 路由、服务端组件、客户端组件各写各的,最后散掉
- 类型没有收住,项目越大越容易出现 key 拼错、参数漏传、locale 混乱
next-intl 现在比较适合 App Router 项目的地方,是它把几条常见主线接得比较顺:
- 用
getRequestConfig管理一次请求范围内的国际化配置 - 用
defineRouting、createNavigation把 locale 路由和导航 API 收到一起 - 同时覆盖 Server Components、Client Components、Metadata、Server Actions 等运行位置
- 可以通过 TypeScript augmentation,把
Locale、Messages、Formats都约束起来
所以它不只是解决“翻译字符串”,而是把 Next.js 里的国际化运行时和类型层一起收住。
一套比较稳的基础接线
如果项目是 App Router,并且希望 locale 进入 URL,一般会把结构收成下面这几层。
1. routing.ts 作为路由中心
import {defineRouting} from 'next-intl/routing';
export const routing = defineRouting({
locales: ['zh-CN', 'en'] as const,
defaultLocale: 'zh-CN'
});
它的意义不是“少写一个配置文件”,而是把下面几类信息统一到一个源头:
- 支持哪些 locale
- 默认 locale 是谁
- URL 前缀策略是什么
- 路径是否需要按语言做本地化
- domain routing、cookie、locale detection 要不要启用
项目做大以后,这个“单一入口”很重要。否则 locale 列表会散落在中间件、路由跳转、表单校验和类型声明里。
2. navigation.ts 包一层 next-intl 导航 API
import {createNavigation} from 'next-intl/navigation';
import {routing} from './routing';
export const {Link, redirect, usePathname, useRouter, getPathname} =
createNavigation(routing);
这样做之后,项目里尽量统一从自己的 i18n/navigation 导出里拿导航能力,而不是到处混用原生 next/navigation 和自己拼 locale。
好处很直接:
- 语言切换时不会漏掉 locale
- pathname 做本地化时,调用层基本不用重写
- 后面如果改
localePrefix或pathnames,受影响面更小
3. request.ts 负责请求级配置
import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './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
};
});
这层是整个 next-intl 的“总闸门”。
这里通常会放:
- locale 校验和兜底
- messages 加载
- 全局
formats now、timeZoneonError、getMessageFallback
如果项目后面要接 CMS、远程词条服务,或者做按租户加载词条,通常也是从这层扩展,而不是去改每个页面。
4. [locale] 段作为路由承载点
import {NextIntlClientProvider, hasLocale} from 'next-intl';
import {notFound} from 'next/navigation';
import {setRequestLocale} from 'next-intl/server';
import {routing} from '@/i18n/routing';
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{locale: string}>;
}) {
const {locale} = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}
这一步做了三件关键事情:
- 校验传入 locale 是否合法
- 用
setRequestLocale(locale)给当前请求绑定 locale - 用
NextIntlClientProvider让客户端组件也能接到配置
如果你需要静态渲染,还要记得配合 generateStaticParams。next-intl 官方文档里也特别强调了:setRequestLocale 要在调用 useTranslations、getMessages 这类 API 之前执行。
常见高级用法
真正让 next-intl 和“拿 JSON 查表”这类轻方案拉开差距的,往往不是基础接线,而是下面这些能力。
1. 服务端和客户端都能优雅取词
同步组件里常见的是:
import {useTranslations} from 'next-intl';
export default function Profile() {
const t = useTranslations('Profile');
return <h1>{t('title')}</h1>;
}
异步组件、generateMetadata、Server Actions 这类位置,则更适合:
import {getTranslations} from 'next-intl/server';
const t = await getTranslations({locale, namespace: 'Metadata'});
这会让“服务端场景也要多语言”变成一件自然的事,而不是把文案逻辑硬塞回客户端。
2. t.rich(...) 处理富文本
很多项目重新上 next-intl 后,用得最多的高级能力往往就是 t.rich(...)。
{
"hero": "阅读 <link>完整文档</link> 了解更多。"
}
t.rich('hero', {
link: (chunks) => <a href="/docs">{chunks}</a>
});
这类写法适合:
- 一段文案里夹一个链接
- 带强调、加粗、斜体的营销文案
- 可复用的富文本标签体系
实际项目里,更省心的做法通常是把常用标签抽成 RichText 组件或 tags 工具,而不是每个页面都手写一遍 <b>、<a>、<p> 的映射。
3. t.raw(...) 适合拿结构化内容
这个特性不算最常提,但有些场景挺顺手。
{
"seo": {
"keywords": ["next-intl", "App Router", "i18n"]
}
}
const keywords = t.raw('seo.keywords') as string[];
t.raw(...) 适合拿这类“不想先格式化成字符串”的值,比如:
- 数组
- 对象
- 结构化配置
- 想直接交给别的函数消费的原始数据
但它也有一个边界:既然拿的是原始值,就不会像 t()、t.rich() 那样帮你做消息格式化。一般更适合配置数据,而不是最终要渲染给用户看的正文。
4. ICU 插值、复数、选择器
next-intl 的消息层不只是“替换变量”,而是完整走 ICU Message 这一套。
{
"greeting": "你好,{name}",
"followers": "你有 {count, plural, =0 {0 个粉丝} =1 {1 个粉丝} other {# 个粉丝}}",
"membership": "{role, select, admin {管理员} editor {编辑} other {普通用户}}"
}
t('greeting', {name: user.name});
t('followers', {count: stats.followers});
t('membership', {role: user.role});
select 这个点很实用。它适合把“根据输入值切换不同文案”的逻辑收回消息层,而不是在组件里写一串 if/else 或三元表达式。
常见场景有:
- 用户角色
- 订单状态
- 支付方式
- 渠道来源
再加上 plural、selectordinal 这些能力,很多原来散在业务代码里的文案判断都可以收回消息层。尤其是:
- 复数
- select
- ordinal
- 数字和日期嵌入文案
项目国际化做深一点,这一层几乎迟早都会用到。
5. 数字、日期、相对时间格式化
如果值本身不是一段消息,而是“独立的格式化值”,更适合用 useFormatter():
import {useFormatter, useNow} from 'next-intl';
function PriceAndTime() {
const format = useFormatter();
const now = useNow({updateInterval: 1000 * 10});
return (
<>
<span>{format.number(499.9, {style: 'currency', currency: 'USD'})}</span>
<span>{format.relativeTime(new Date('2026-05-30T08:00:00.000Z'), now)}</span>
</>
);
}
这类能力很容易被忽略,但也最容易露出“多语言只做了一半”的问题:
- 中文和英文的日期展示差异
- 货币符号位置差异
- 千分位、百分比、小数精度差异
- “2 hours ago” 这种相对时间差异
如果项目已经用了 next-intl,这部分最好直接走它,不要再和 dayjs 文案拼接、手写 toLocaleString() 混着来。
6. Metadata、Server Actions、OG 图这些非组件位置
这是 next-intl 在 App Router 下非常实用的一点。
比如 generateMetadata:
import {getTranslations} from 'next-intl/server';
export async function generateMetadata({params}: {params: Promise<{locale: string}>}) {
const {locale} = await params;
const t = await getTranslations({locale, namespace: 'Metadata'});
return {
title: t('title'),
description: t('description')
};
}
Server Action 里返回用户可见错误文案,也可以直接本地化:
import {getTranslations} from 'next-intl/server';
export async function loginAction(formData: FormData) {
'use server';
const t = await getTranslations('LoginForm');
if (!isValid(formData)) {
return {error: t('invalidCredentials')};
}
}
这类位置如果不统一处理,最后常见的结果就是:
- 页面正文是多语言的
- SEO title 还是写死的
- 表单错误提示还是英文
- Open Graph 图文案没有跟着 locale 走
路由层的几个进阶配置
如果项目真的把多语言当成正式能力,而不是 demo,路由配置一般还会继续往下细化。
localePrefix
常见三种:
always:/en/aboutas-needed:默认语言不带前缀,其他语言带never:URL 不展示 locale,内部仍通过[locale]承载
经验上:
- 内容站、SEO 站点,默认优先考虑
always或as-needed - 强用户态应用,且 locale 更像“个人偏好”时,
never也可能成立
pathnames
如果你希望路径本身也本地化,可以直接在 routing.ts 里做映射:
pathnames: {
'/about': {
de: '/uber-uns'
},
'/services': {
de: '/leistungen'
}
}
这比“页面里手动判断当前语言然后跳不同路径”要干净很多。它本质上是:
- 外部 URL 可以按语言变化
- 内部文件系统路由保持稳定
这对 SEO、多语言站点结构、CMS 驱动 slug 都很有帮助。
domains、localeDetection、localeCookie
这三个配置更像“正式国际化项目”的工程选项:
domains:不同域名承载不同 localelocaleDetection:是否根据请求头和 cookie 自动判定语言localeCookie:是否记住用户最近一次选择的 locale
这几个点很少是“语法题”,更多是产品和 SEO 策略题。技术上都能配,是否值得配,要看业务。
类 型增强:先把 next-intl 官方提供的类型能力用起来
在自己封装业务级 key 类型之前,更值得先把 next-intl 原生的 TypeScript augmentation 用起来。
核心入口通常是一个全局声明文件,比如 global.ts:
import {routing} from '@/i18n/routing';
import {formats} from '@/i18n/request';
import messages from './messages/zh-CN.json';
declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof messages;
Formats: typeof formats;
}
}
这三项分别解决三类问题。
1. Locale:把 locale 收成受控联合类型
这样以后:
useLocale()返回值不再只是宽泛的string<Link locale="...">会有约束- 你自己定义接受 locale 的函数,也可以直接复用
next-intl提供的Locale类型
这一层看起来不花哨,但很值。因为 locale 一旦不受控,很多下游逻辑都会被迫退回 string。
2. Messages:让 t() 的 key 真正受约束
这往往是最先见效的一层。
配上之后:
useTranslations('Profile')的 namespace 会校验t('title')的 key 会校验- 写错 key 时,问题会在编译阶段暴露
这对大型项目很重要,因为多语言问题通常不是“语法报错”,而是上线后某个页面静默丢词。
3. 类型化参数:让插值参数也受约束
如果消息是:
{
"title": "你好,{firstName}"
}
那理想状态下,t('title') 不传 firstName 就应该报错。
next-intl 官方现在提供了 createMessagesDeclaration 这条路,给消息 JSON 生成 .d.json.ts 声明文件,来补上 JSON 值类型推断过宽的问题。
这意味着多语言类型系统不只校验“key 对不对”,还能继续校验“参数齐不齐”。
在官方能力之上,再封装业务级 key 类型
很多团队做到这里还不 够,还会继续加一层业务限制。
典型诉求是:
- 并不是所有页面都应该能拿任意 namespace
- 某些配置项只能引用指定范围内的多语言 key
- 某些业务模块希望把“可用 key”收成一个更语义化的类型
这时才值得引入自己的 I18nKey 一类封装。
一种常见的封装方式
先基于消息结构,抽象一个“命名空间 + key 路径”的泛型。
type I18nMessages = typeof import('../../messages/zh-CN.json');
type Namespace = keyof I18nMessages & string;
type I18nKey<TNamespace extends Namespace> = `${TNamespace}.${string}`;
上面只是一个起点。再往下,你可以继续把第二段路径也收窄,而不是停在 ${string}。
type MarketingKey = I18nKey<'Marketing'>;
type AccountKey = I18nKey<'Account'>;
然后在业务配置里这样用:
type FeatureCard = {
titleKey: MarketingKey;
descriptionKey: MarketingKey;
};
这样配置型数据就不会再随便塞进:
- 任意裸字符串
- 其他 namespace 的 key
- 拼错但编译不报错的文案路径
什么时候值得自己封装
下面几类场景通常值得:
- 页面配置、菜单配置、表单 schema、卡片定义这类“配置驱动 UI”
- 同一个模块需要反复引用一组稳定 key
- 想把 key 作为函数参数或 DTO 字段传递
什么时候不要过度封装
下面几类场景就别太激进:
- 只是页面内部偶尔写一个
t('title') - 文案结构还在频繁调整
- key 动态拼接特别多,暂时收不住
- 团队对复杂 TS 泛型维护成本比较敏感
换句话说,业务级 key 类型最适合“稳定配置面”,不适合所有地方一股脑铺开。
一个更实用的分层思路
如果你想把这套东西做得既稳又不重,可以按三层理解。
第一层:官方原生能力
useTranslationsgetTranslationsuseFormatterdefineRoutingcreateNavigationAppConfigaugmentation
这层应该优先吃满。
第二层:项目级封装
i18n/routing.tsi18n/navigation.tsi18n/request.tsmessages加载约定RichText公共标签
这层解决的是“别让 next-intl 在项目里散掉”。
第三层:业务级类型封装
I18nKey<TNamespace>MarketingI18nKeyAccountI18nKey- 配置对象里的
titleKey/labelKey/descriptionKey
这层解决的是“哪些业务面需要更强的 key 约束”。
顺序最好不要反过来。前两层还没整理好,就急着把第三层写得很复杂,最后多半只会让类型系统变成负担。
一些很容易踩到的点
1. 动态拼接 key 要克制
比如:
t(`${section}.${field}.label`);
这种写法一多,类型系统就很难真正帮上忙。
更好的方向通常是:
- 把动态 key 收成受控映射
- 把配置抽成 definition
- 在配置层用受限类型,渲染时只消费
也就是说,尽量让“动态”发生在受控配置里,而不是散在 JSX 中间。
2. 不要把所有 fallback 都交给运行时
getMessageFallback、onError 很有用,但它们更适合兜底和监控,而不是长期替代缺词治理。
更稳的思路通常是:
- 开发期尽量严格暴露缺词
- 生产期允许优雅降级
- 结合日志或错误上报追踪缺词
3. NextIntlClientProvider 的继承规则要搞清楚
它会继承上层 provider 的配置,但 onError 和 getMessageFallback 这两个函数型配置不会自动跨到客户端,需要额外用一个 client provider 去补。
这个点在做统一错误处理时很容易忽略。
4. 消息结构不要一开始就扁平化到失控
next-intl 的 key 用 . 表达嵌套路径,所以消息结构更适合按 namespace 和模块组织,而不是所有 key 全拍平在一层。
项目越大,这个差异越明显:
- namespace 清楚,
useTranslations('Checkout')会很好用 - 如果全是平铺 key,维护和复用都很快失去边界
推荐的落地顺序
如果项目正在“重新启用 next-intl”,我更建议按下面顺序推进:
- 先把
routing.ts、navigation.ts、request.ts、[locale]这一套骨架立稳 - 再把
useTranslations/getTranslations/useFormatter的使用边界理顺 - 接着补
AppConfigaugmentation,把Locale、Messages、Formats先约束起来 - 最后只在配置驱动、复用频繁的业务面,再加自己的
I18nKey一类封装
这个顺序的好处是每一步都能单独成立,不容易一上来就把类型系统做重。
一句话总结
重新使用 next-intl,真正值得重视的不是“把文案搬进 JSON”。
更关键的是把下面几件事一起做好:
- 用
next-intl把 App Router 下的路由、服务端和客户端取词串起来 - 用 ICU、
t.rich(...)、t.raw(...)、格式化 API 把多语言能力补完整 - 用
AppConfigaugmentation 先收住 locale、messages、formats - 再在稳定的业务配置面上,按需加一层自己的
I18nKey类型约束
这样项目得到的就不只是“能翻译”,而是一套更稳的国际化工程结构。