现在让 Cursor 或 Claude Code 给 Next.js 应用添加 i18n,你很可能会得到 middleware.ts,每个 layout 里都有 setRequestLocale,还有在 async 页面里调用的 useTranslations——这套写法在 Next.js 15 上是正确的,到了 Next.js 16 就落后了一代。globalize.now 是 AI 驱动的本地化基础设施,所以这一层正是我们持续关注的:路由接线变了,它所提供的目录没有变。下面介绍哪些地方变了,如何判断你生成的代码属于哪个时代,以及三个只需一下午、不用重写的修复方案。
这些都不是破坏性变更,也正因如此,才容易被忽略。
Next.js 16 在 i18n 方面有哪些变化?
负责协商 locale 的那个文件改名了。Next.js 文档将 middleware 文件约定标记为已弃用并重命名为 proxy,自 v16.0.0 起生效,官方给出的理由只是措辞问题——“middleware”总被误解为 Express 中间件,所以这个功能被重命名,以准确描述它实际承担的网络边界角色。
v16 中,除了重命名还有两点行为变化。Proxy 默认使用 Node.js runtime,并且不再支持每个文件单独设置的 runtime 配置项,一旦设置,Next.js 会直接报错。Proxy 也不支持静态导出,如果 locale 协商是你无法导出静态站点的唯一原因,这一点就很重要。
具体到 locale 路由,next-intl 的配置指南现在将该文件写作 src/proxy.ts,并明确说明在 Next.js 16 之前它叫 middleware.ts。文件内部的 import 保持不变:
// src/proxy.ts
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
};
注意这个不对称之处,很容易踩坑:文件现在叫 proxy.ts,而 import 路径仍是 next-intl/middleware。把 import 也改掉是常见的矫枉过正。
为什么 AI 生成的 i18n 代码会落后一个版本?
因为编程 Agent 预测的是出现频率最高的写法,而频率最高的写法恰恰是积累了多年的那种。2026 年末之前,关于 App Router locale 路由的每篇博客、每个 Stack Overflow 回答和每个 GitHub 示例,写的都是 middleware.ts。模型把这批语料和仅有几周的 v16 文档放在一起权衡,就会信心十足地选用旧写法,完全意识不到已经发生过重命名。
这和我们之前写过的问题是同一个机制:Cursor 在 i18n 配置完成后仍不断添加硬编码字符串——Agent 复现的是统计上最常见的代码库,而不是你的代码库。这也是为什么指令文件只能部分解决 GitHub Copilot 的这个问题:聊天界面会读取的规则,行内补全不一定会读。
实际影响范围不大,但确实存在。生成的配置能正常运行,所以不会有明显报错。直到你遇到 bug 去搜索,才发现所有最新的答案描述的文件你根本没有。
怎么判断我生成的配置属于哪个时代?
四条 grep 命令。在项目根目录运行,大约一分钟就能知道结果。
# 1. Pre-16 locale negotiation file
ls middleware.ts src/middleware.ts 2>/dev/null
# 2. Legacy static-rendering API
grep -rn "setRequestLocale" app src 2>/dev/null
# 3. Hooks called inside async components
grep -rn -B3 "useTranslations" app | grep -n "async function"
# 4. Which next-intl era the config reads from
grep -rn "root-params\|await params" src/i18n/request.ts 2>/dev/null
第 1 条和第 2 条命中,说明是 16 之前的脚手架代码。第 3 条命中,说明存在真正的运行时错误,只等第一个渲染该组件的请求触发。第 4 条没有命中,说明你的 request config 仍在用旧方式读取 locale。
一定要把 middleware.ts 重命名为 proxy.ts 吗?
不急,但也别手动改。Next.js 提供了 codemod,可以同时重命名文件和导出的函数:
npx @next/codemod@canary middleware-to-proxy .
这次重命名是弃用而非移除,所以现有的 middleware.ts 仍能正常工作。仍建议执行的原因是维护成本:文件名和当前文档一致后,今后搜到的每条结果都适用于你的仓库。放着不改,每次调试都要多付出一点代价,而且会一直持续下去。
next-intl 中的 setRequestLocale 已被弃用了吗?
它被标记为 legacy,这比 deprecated 的说法要温和,值得精确理解。next-intl 文档把 setRequestLocale 描述为在 next/root-params 出现之前存在的 API,说明它出于向后兼容仍受支持,并推荐改用 next/root-params。
新写法在 request config 内部读取匹配到的 locale,而不是手动把它逐层传给每个 layout 和 page:
// src/i18n/request.ts
import * as rootParams from 'next/root-params';
import {notFound} from 'next/navigation';
import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './routing';
export default getRequestConfig(async ({locale}) => {
if (!locale) {
const paramValue = await rootParams.locale();
if (hasLocale(routing.locales, paramValue)) {
locale = paramValue;
} else {
notFound();
}
}
return {locale};
});
next/root-params 在 Next.js 16.3 及以上版本中默认可用;更早的版本需要通过 experimental.rootParams 启用。按这种方式配置,静态渲染自然就有了,前提是你仍要为 [locale] 段导出 generateStaticParams。
旧方式要求在你希望静态渲染的每个页面和 layout 中,都要在其他 next-intl 调用之前先调用 setRequestLocale,因为 Next.js 会独立渲染 layout 和页面。这种规则,AI Agent 写到第五个文件时就会忘。去掉这个要求,也就消灭了这一整类 bug。
为什么 useTranslations 在我的 async Server Component 里会崩溃?
因为 hook 不能在 async 组件中调用,而 useTranslations 是 hook。这是 React Server Components 的限制,不是 next-intl 的特例,next-intl 给出的方案是一组对应的、可 await 的函数:
// Async component — await the server API
import {getTranslations} from 'next-intl/server';
export default async function ProfilePage() {
const user = await fetchUser();
const t = await getTranslations('ProfilePage');
return <h1>{t('title', {username: user.name})}</h1>;
}
// Non-async component — the hook is correct here
import {useTranslations} from 'next-intl';
export default function UserDetails({user}) {
const t = useTranslations('UserProfile');
return <h2>{t('title')}</h2>;
}
getFormatter、getNow、getTimeZone、getMessages 和 getLocale 遵循同样的模式。第二个例子值得多看一眼:没有交互功能的非 async 组件属于共享组件,next-intl 会根据它是在服务端还是客户端渲染来解析出正确的实现。所以在 Server Component 里使用 useTranslations 并没有错——在 async 组件里调用它才是错的。
为什么会出现 NextIntlClientProvider 的 context 报错?
next-intl 的故障排查文档给出了两种原因,修复方式正好相反。一种是组件确实在客户端运行,而上层没有 provider:这时给它包一层 provider,并传入它需要的 messages。另一种是你以为是服务端渲染,组件却滑进了客户端模块图:这时应在 Server Component 中通过 children 传入,而不是在其内部直接导入。
第二种情况在 AI 生成的应用里最常见:Agent 为了让交互跑起来,会大量添加 'use client',而这个指令会沿导入图向下传染。推荐的做法是在服务端翻译,再把成品字符串传过边界:
import {useTranslations} from 'next-intl';
import Expandable from './Expandable'; // 'use client'
export default function FAQEntry() {
const t = useTranslations('FAQEntry');
return <Expandable title={t('title')}>{t('description')}</Expandable>;
}
如果某个组件确实需要在客户端使用 messages,可以只为那棵子树单独包一层 provider,而不是把所有 messages 都发送到浏览器——根 provider 上的 messages={null} 则一条都不会传。
那么,这些做法都不会改变什么?
你的语言文件。上面每一种修复都只涉及路由和渲染的接线,应用加载的 JSON 完全不受影响。这一点值得记住:接线是一个下午就能搞定、一年才改一次的活,而翻译目录会随着新 UI 上线每周都在腐烂。
这正是我们构建产品时所依据的分工。next-intl 及同类库负责在运行时提供翻译——我们不与它们竞争;如果你还在选型,next-intl vs react-i18next vs Lingui 对比了各自的取舍。globalize.now 位于更上一层,负责生成这些运行时读取的键和语言文件,因此 Next.js 集成 并不关心你的语言协商是放在 middleware.ts 还是 proxy.ts 里。
转换是一次性的,并且在应用内完成:连接仓库后,globalize.now 对代码库执行一次转换,并提交一个包含翻译目录的 PR。此后,推送任务会在新的目录单元出现时对其翻译——翻译文件为何总是不同步 描述的正是这种失效场景,也是你应当在第三种语言上线之前、而不是之后补上这个缺口的原因。
如果你的项目已经手写了 next-intl 配置,我们专门写过哪些内容会动、哪些不会动。如果还没有,Cursor 实操指南 是最短的上手路径——只是它生成的文件沿用了旧版命名约定,所以之后记得运行 codemod。
从哪里开始
跑一遍这四条 grep。如果前两条有命中,运行 codemod,把请求配置迁移到 next/root-params,就跟上最新版本了。接着去看语言文件,因为下个月仍在漂移的正是这部分。如果你交付的是 AI 构建的应用,希望翻译目录有人托管而不是自己维护,vibe coder 页面 有整体介绍,定价 则单独成页。
globalize.now 把硬编码的应用文案转换为可直接翻译的语言文件,并在你持续发布时保持同步更新。
免费试用 globalize.now