最近连着写了几个 Next.js 项目,功能本身都挺顺,结果全卡在同一类问题上,本地 next dev 好好的,一 build 或者一上线就出事。这些坑清一色不属于「代码写错了」,而属于「渲染模式没搞对」,报错信息还经常指向别处,特别费时间…

这里记录下我踩过的几个,尽量把文件行号都写上~

坑一:构建期是没有数据库的

vns-next 是 Galgame 资源站,Prisma + Postgres,部署走 Docker Compose 自托管。本地跑得好好的,一进 Dockerfile 的多阶段构建就炸。原因是 Next 默认会在 next build 阶段把 RSC 页面静态预渲染一遍,而 builder 那一层根本没有数据库容器,任何查库页面必然在 build 时抛错。

修法在 commit b07e20d:根 layout 里加一行 export const dynamic = "force-dynamic"sitemap.ts 加上 feed.xmlllms.txtindex.xml 三个 route handler 同步声明。

// src/app/layout.tsx:11-13
// 全站动态渲染:内容页在请求时查库(Docker 构建环境没有数据库,
// 静态预渲染会在 build 阶段连库失败);热点查询已有内存缓存兜底
export const dynamic = "force-dynamic";

值得记一笔的是代价:git show --stat b07e20d 显示 5 files changed, 16 insertions(+),其中真正的声明就是 5 个文件各一行,剩下 11 行全是注释和空行。把整站从静态切成动态就这么点东西,反过来说,也就意味着你可以毫无察觉地把整站切成动态捏…

验证手段我觉得比修复本身更值得抄:commit body 里写的是「已用死库 DATABASE_URL 本地复现构建通过」。就是拿一个连不上的 DATABASE_URL 在本地把 Docker 构建环境复现出来,先看它挂,再验它好。不然改完只能靠推到服务器上赌~

不想整站转动态的话还有两条路。mikiacg 是把 generateStaticParamstry/catch 整个包住:

// src/app/video/tag/[slug]/page.tsx:25-36
export async function generateStaticParams() {
  try {
    const popularTags = await prisma.tag.findMany({
      where: { videos: { some: { video: { status: "PUBLISHED" } } } },
      take: 50,
      orderBy: { videoCount: "desc" },
      select: { slug: true },
    });
    return popularTags.map((tag) => ({ slug: tag.slug }));
  } catch {
    return [];
  }
}

构建期连不上库就预渲染 0 个页面,全部退化成运行时生成,但至少 build 不会红。thdl 走的是另一条:首页声明了 revalidate = 60,查询前先 let latest = [] 打底,再用 try{}catch{latest = []} 兜住 DB 失败,src/app/(site)/page.tsx:18-27 就是这个写法。

坑二:写了 revalidate 不等于就 ISR 了

这条最气人,因为它不报错~

thdlsrc/app/(site)/resources/page.tsx:6 老老实实写了 export const revalidate = 30,可同一个文件第 11 行是 const sp = await searchParams。用了 searchParams 的页面在 Next 16 下会被判定动态渲染,那条 revalidate 就是一句无效声明。

同仓库的 [slug]/page.tsx:17 一样写了 revalidate = 30,里面调了 getSession(),而 src/lib/get-session.ts 内部是 auth.api.getSession({ headers: await headers() })。读了 headers(),路由照样被打成动态,ISR 同样落不下来。

更离谱的一例在 bbps。这站是 output: 'export' 纯静态导出,git show 99b11c53^:src/app/blog/[id]/page.tsx 能看到第 7 行赫然是 export const revalidate = 600。要注意那会儿这个文件还在现役用 WordPress API 拉数据,这行是当时特意为 WP 拉取写的,不是什么上个时代剩下的垃圾,只不过在 output: 'export' 下它从写下的第一天起就没生效过…

这类声明的共同点是:不报错、不告警、tsc 也过,只会静静地不生效… 想确认到底有没有生效,还是得看 next build 输出里那个路由前面的标记就行。

坑三:hydration 三连

hydration mismatch 我按现象归了个类,基本逃不出这三种:

现象根因我的解法
持久化状态首帧闪回默认值localStorage 与 SSR 首帧不一致mounted 门控,挂载前恒返回默认值
秒级计时器 / 时间戳报 mismatch服务端和客户端的 Date.now() 必然不同suppressHydrationWarning
切主题白屏或报错next-themes 往 <html> 注入 class<html suppressHydrationWarning>

第一种,vns-next 用 zustand persist 存设置,解法抽成了一个 hook:

// src/stores/settings.ts:57-70
/**
 * 水合安全地读取单个设置项:挂载前恒返回默认值,
 * 避免 localStorage 持久化状态与 SSR 首帧不一致(hydration mismatch)。
 */
export function useSettingValue<K extends keyof SettingsData>(key: K): SettingsData[K] {
  const value = useSettings((s) => s[key]);
  const [mounted, setMounted] = useState(false);
  useEffect(() => { setMounted(true); }, []);
  return mounted ? value : defaultSettings[key];
}

第二种没得治,页脚那个「已运行 X 天 X 时 X 分 X 秒」的计时器,服务端渲的秒数跟客户端对不上是物理规律。src/components/layout/running-time.tsx:30-31 直接认了:

// 挂载前渲染占位,避免 SSR 与客户端时间不一致导致的 hydration 报错
return <span suppressHydrationWarning>{text ?? "…"}</span>;

但这个属性我是当消耗品用的,vns-next 全站只有 3 处(layout.tsx:38running-time.tsx:31dashboard/work-form.tsx:319)。毕竟它只是把警告闭嘴,问题本身还在那儿呢。

第三种最常见。thdllayout.tsx 里,suppressHydrationWarning:36)和 next-themes 的 ThemeProvider:41)是配套的,缺一个就报。acgn-flowef73bb4 一次列了 5 条:layout.tsx:109 的 body 加属性、header 里把 motion.span 换成纯 span、header 用 CSS 的 max-h/opacity 替掉条件渲染、加 mounted 门控 auth UI、还给 PageWrapper 和 FadeIn 也补了 mounted。结果两分钟后 46c6101 又补了一处 settings-panel 的 next-themes mismatch,可见这玩意儿是会漏的捏…

静态站也一样逃不掉。bbpssrc/contexts/locale-context.tsx:96-104,effect 里必须重新读一次 localStorage:

// 首次挂载:按偏好跳转(直接读 localStorage,避免 hydration 闭包捕获到服务端快照 'system')
useEffect(() => {
  const stored = localStorage.getItem(STORAGE_KEY) as LocalePref | null
  // ...
}, [])

根源在同文件 :74-76getPrefServerSnapshot() 恒返回 'system'useSyncExternalStore 那个值被 effect 的闭包捕获住了,直接用就永远是服务端快照。

再补一个更冷的:home 那次 68e9570,static export 把构建时的年份烤进了页脚 HTML,客户端一跨年就 mismatch,只好在 app-footer.tsx:38 上按了个 suppressHydrationWarning。这站后来整个迁出了 Next,那行属性就再没人管过了…

坑四:SSR 端 dayjs 的 locale 会注册到另一个实例上

这条最阴,因为在客户端看着完全是对的~

mikiacg 的 commit 99be0f1:副作用式的 import "dayjs/locale/zh-cn" 在 Next 服务端的 ESM/CJS interop 下,会把 locale 注册到与业务代码不同的那个 dayjs 实例上。dayjs.locale("zh-cn") 找不到就 fallback 回 en,于是 SSR 吐出来的 HTML 是「16 days ago」而不是「16 天前」。

修法是三重保险:

// src/lib/format.ts:1-8
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
// 直接 import locale 对象并手动 register;副作用 import "dayjs/locale/zh-cn" 在 Next.js
// server 端的 ESM/CJS interop 下会把 locale 注册到另一个 dayjs 实例上,导致 fromNow() 始终输出英文
import zhCnLocale from "dayjs/locale/zh-cn";

dayjs.extend(relativeTime);
dayjs.locale(zhCnLocale, undefined, true);

然后 :33 的调用现场再兜一次底:return dayjs(date).locale("zh-cn").fromNow();。这类靠模块副作用做全局注册的库,在 RSC 里我现在都会多瞄一眼…

坑五:入场动画能把整页卡在半透明

vns-nextsrc/app/template.tsx 全文 14 行,注释里写清了为什么不用 framer-motion:

用 CSS 动画(tw-animate-css)而非 framer-motion:opacity/transform 跑在合成器线程,
rAF 被节流/冻结(后台标签页、嵌入式 webview、低性能设备)时也不会把整页卡在半透明中间态,
且动画不可用时元素静态即为最终可见态。prefers-reduced-motion 用 motion-reduce 降级。

JS 驱动的入场动画大多是 rAF 把 opacity 从 0 推到 1,一旦 rAF 被浏览器节流甚至冻结,动画停在 0.3 就再也不动了,用户看到的是一整页半透明。CSS 动画跑在合成器线程上不吃这套,而且退一万步动画彻底不可用时,元素的静态样式就是最终可见态。

这条理由在这个仓库里重复出现了 5 处,commit 123f722 的 body 里也记了这个修复~

顺手记两条 Next 16 的改名

一个是 middleware 改名成 proxyvns-next 里的文件就是 src/proxy.ts,导出的函数也叫 proxy,仓库里压根不存在 src/middleware.ts。文件头 :4 的注释是「尾斜杠自管重定向(Next 16 的 middleware 更名 proxy)」。

另一个是 revalidateTag 现在需要显式传第二个参数(profile),缺省会走废弃告警路径。这条是在另一个项目升级 Next 16 时撞到的,同一个仓库里并存着两种写法,一处传对象、一处传字符串,两种都合法,就是风格没统一而已啦。

反正大概率我还会再踩一遍,所以先记下来…