params 是一个 Promise;metadata 是按字段合并的,不是深度合并;PageProps/LayoutProps 只有在有东西生成它们之后才存在;pre-paint 的主题脚本必须由服务端渲染。 四个里有三个是静默失败:build 绿的,类型绿的,HTML 是错的。 下面所有内容都来自我维护的五个 Next.js 16.2.10 / React 19.2.7 代码库。
1. params 是一个 Promise,而 await 它只是简单的那一半
迁移本身是机械劳动。params 从 15 开始就是 Promise 了;16 做的事情是把那个让你可以忽略这件事的临时同步访问拿掉。下面是 launch-template/src/app/[locale]/layout.tsx 里真实的签名:
interface LocaleLayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale } = await params;
if (!hasLocale(LOCALES, locale)) notFound();cookies() 和 headers() 也走了同样的路。来自 launch-template/src/lib/preview.ts:
export async function getNavOverlay(): Promise<boolean> {
try {
const c = await cookies();
const v = c.get(NAV_OVERLAY_COOKIE)?.value;真正花掉我时间的是二阶效应。一旦 layout 变成 async 并且 await 了 params,它下面的每个 server component 仍然需要 locale——而它们并不会收到 params。关键的那一行,是我最后在同一个 layout 里写下的那条关于顺序的注释:
// Required so server components rendered below see the right locale even
// though they don't re-await `params`. Must come BEFORE any other
// `getTranslations()` / `getMessages()` call.
setRequestLocale(locale);把它放在第一次 getMessages() 之后,你就会在一个前缀完全正确的 URL 里渲染出默认 locale。没有报错,没有警告。
2. metadata 是按字段合并的,不是深度合并
这一个是带着 bug 发出去的,而且我好几周都没发现,因为工具链里没有任何东西会抱怨。
症状:自己声明了 openGraph 的页面,在 view-source 里缺了 og:site_name、og:type 和 og:locale。这三个我写在了它们上层的 layout 里。我以为页面加一个 openGraph.title 会叠加上去。
并不会。来自 Next 自己的文档(node_modules/next/dist/docs/01-app/03-api-reference/04-functions/generate-metadata.md):
同一条路由中多个 segment 导出的 metadata 对象会被浅合并,形成这条路由最终的 metadata 输出。重复的 key 会按照它们的顺序被替换。
浅合并意味着 openGraph 这个 key 就是一个值。设置了它的页面会把 layout 的那个对象整个替换掉。twitter 一样。alternates 也一样——而这个坑了我两次,因为在 launch-template/src/app/layout.tsx 里,根 layout 把 RSS 自动发现声明在了那里:
alternates: {
types: { "application/rss+xml": FEED_URL },
},所以只要任何页面加上了 alternates.canonical——每个页面都需要它来做 hreflang——那个页面上的 <link rel="alternate" type="application/rss+xml"> 就被静默丢掉了。
解法不是「记住这件事」。解法是把基础字段放进一个每处声明都会展开的 helper,让它们不可能被丢掉。levain-template/src/lib/seo.ts:
export function openGraphBase(lang: Lang): {
type: "website";
siteName: string;
locale: string;
} {
return {
type: "website",
siteName: siteConfig.name,
locale: lang === "es" ? "es_CL" : lang === "fr" ? "fr_FR"
: lang === "pt" ? "pt_PT" : "en_US",
};
}用法是 openGraph: { ...openGraphBase(lang), title, description, url, images }。同一个文件里的 alternates helper 会在 canonical 旁边重新声明 feed 的 types,原因正是上面这个。
下次怎么抓到它:这东西是可以 grep 的。每个声明了这个字段的文件,都必须同时引用那个 helper。
fail=0
for f in $(grep -rl "openGraph:" src/app); do
grep -q "openGraphBase" "$f" || { echo "MISSING: $f"; fail=1; }
done
exit $faillevain-template 里有十二个文件声明了 openGraph:;十二个全都展开了 base。把它跑进 CI,失败模式就不再是「有人忘了」。
3. tsc --noEmit 在干净的 checkout 上会失败
症状:全新 clone,pnpm install,npx tsc --noEmit,然后是十九个错误:
src/app/[locale]/pricing/page.tsx(20,10): error TS2304: Cannot find name 'PageProps'.
src/app/[locale]/auth/login/layout.tsx(13,10): error TS2304: Cannot find name 'LayoutProps'.让人困惑的地方在于,这些名字并不是从哪里 import 进来的,所以根本没有缺失的依赖可以追。它们是全局的。而且它们是生成出来的。next-env.d.ts 只有三行代码:
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";第三行就是全部答案。PageProps 和 LayoutProps 声明在 .next/types/routes.d.ts 里——一个 build artifact——和一个列出了每条路由及其 params 的 ParamMap 接口放在一起。(next-env.d.ts 本身也是生成的,而且被 gitignore 了,所以在全新 clone 上根本没东西可读:线索的起点是一个你没有的文件,指向一个你没有的目录。)所以下面这个签名里的 props.params,类型来自一个在有东西生成它之前根本不存在的文件:
export async function generateMetadata(
props: PageProps<"/[locale]/pricing">
): Promise<Metadata> {
const { locale } = await props.params;我确认这个机制的方式是:把那一个生成出来的文件从 program 里排除掉,再对同样的源码做类型检查——十九个 TS2304,全都是 PageProps 或 LayoutProps,其他错误一个都没有。
解法是一个我原本不知道存在的一等命令:
next typegen && tsc --noEmitnext typegen 会在不跑完整 build 的情况下写出路由类型。它自己的文档说,它存在的原因正是「路由类型只在 next dev 或 next build 期间生成,这意味着直接跑 tsc --noEmit 不会校验你的路由类型」。如果你的 CI 里类型检查 job 和 build job 是分开的,那个 job 要么是直接在失败,要么是在拿你的路由去对着上一次 build 留在 .next/types 里的东西做检查。
4. pre-paint 的主题脚本必须由服务端渲染
症状:任何切换过主题的人,重新加载时都会闪一下错误的主题。
我的第一版是一个 client component,用 useEffect 读 cookie,然后在 <html> 上设置 data-theme。属性最后是对的,但它躲不掉那次闪烁:effect 在 hydration 之后才跑,晚了好几次 paint。
机制说破了很简单:只有服务端放进文档里的 markup 才会在 first paint 之前执行。所以这个组件必须是一个纯粹的 server component,吐出一个裸的 <script>。booking-template/src/components/layout/ThemeBoot.tsx 把这条规则写在了文件头的注释里:
* Rules:
* - Must be a plain server component rendering a bare <script> tag.
* - Never use next/script here: React 19 warns on client-rendered inline scripts.
* - Never render this from a client component.launch-template 里的整个文件只有九行——没有 "use client",没有 import(这里省略了 cookie 解析):
export function ThemeBoot({ defaultTheme }: { defaultTheme: "dark" | "light" }) {
const code = `(function(){try{ /* read vs-theme cookie */
document.documentElement.setAttribute('data-theme',v);}catch(e){...}})();`;
return <script dangerouslySetInnerHTML={{ __html: code }} />;
}从根 layout 直接挂在 <head> 里,字体和强调色也是同样的处理:
<html lang="en" className={fontVariables} suppressHydrationWarning>
<head>
<ThemeBoot defaultTheme="dark" /><html> 和 <body> 上的那个 suppressHydrationWarning 不是装饰。脚本会在 React 看到 DOM 之前就修改 <html> 上的属性,所以服务端和客户端的 markup 确实是被设计成不一样的,否则 React 每次加载都会警告。
四个里有三个背后的共同模式
metadata 合并、setRequestLocale 的顺序和主题脚本,失败的方式都一样:build 是绿的,类型是绿的,而浏览器拿到的东西是错的。这三个,除非你去打开 view-source、带着设好的 cookie 重新加载、或者跑一个你专门写的 grep,否则什么都抓不到它们。生成的路由类型是那个反证——它们确实会大声失败,只是不在你平时跑的那条命令里。当一个框架把工作挪进生成的文件、并且把 metadata 做浅合并时,「它编译过了」就不再是证据。
这就是我在 launch 里落地的模式——在线 demo:https://launch.violettadev.com。