最近、Next.js のプロジェクトを立て続けにいくつか書いていて、機能自体はどれも順調だったのに、全部同じ種類の問題で詰まってしまった。ローカルの next dev では何の問題もないのに、build した途端、あるいはデプロイした途端に事故る。こうした罠はどれも「コードの書き間違い」ではなく「レンダリングモードを間違えている」類のもので、しかもエラーメッセージはたいてい別の場所を指しているので、とにかく時間を食う…
ここでは自分が踏んだものをいくつか記録しておく。できるだけファイルの行番号も書いておくね~
罠1:ビルド時にはデータベースが存在しない
vns-next はギャルゲーのリソースサイトで、Prisma + Postgres、デプロイは Docker Compose によるセルフホスト。ローカルでは問題なく動くのに、Dockerfile のマルチステージビルドに入った途端に爆発する。原因は、Next がデフォルトで next build の段階で RSC ページを静的に事前レンダリングするからで、builder のレイヤーにはデータベースのコンテナがそもそも存在しない。DB を参照するページは必ず build 時にエラーを投げることになる。
修正は commit b07e20d にある。ルート layout に export const dynamic = "force-dynamic" を一行追加し、sitemap.ts に feed.xml、llms.txt、index.xml の 3 つの 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 ファイルそれぞれ 1 行ずつで、残りの 11 行は全部コメントと空行だ。サイト全体を静的から動的へ切り替えるのにこれだけしかない、逆に言えば、あなたも気づかないうちにサイト全体を動的にしてしまえるということだね…
検証の方法は修正そのものより真似する価値があると思う。commit body には「死んだ DB の DATABASE_URL を使ってローカルでビルドが通ることを再現済み」と書いてある。つまり、接続できない DATABASE_URL を使ってローカルで Docker のビルド環境を再現し、まず落ちることを確認してから、通ることを確かめるわけだ。そうしないと、修正後はサーバーに push して賭けるしかない~
サイト全体を動的にしたくない場合、あと 2 つの道がある。acg-site は generateStaticParams を try/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 [];
}
}ビルド時に DB へ接続できなければ、事前レンダリングされるページは 0 になり、すべて実行時生成へと退化するが、少なくとも build が赤くなることはない。thdl は別の路線で、トップページで revalidate = 60 を宣言し、クエリの前に let latest = [] で下地を作り、さらに try{}catch{latest = []} で DB の失敗を受け止める。src/app/(site)/page.tsx:18-27 がその書き方だ。
罠2:revalidate を書いたからといって ISR になるわけではない
これが一番腹立たしいやつだ。なぜならエラーを出さないから~
thdl の src/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 の出力にある、ルートの前につくマークを見るしかない。
罠3:hydration 三連
hydration mismatch については現象ごとに分類してみた。だいたいこの 3 種類に収まる:
| 現象 | 根因 | 私の解法 |
|---|---|---|
| 永続化された状態が最初のフレームでデフォルト値に戻る | 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:38、running-time.tsx:31、dashboard/work-form.tsx:319)だ。結局これは警告を黙らせるだけで、問題自体はそこに残っているんだよね。
三つ目が一番よくある。thdl の layout.tsx の中で、suppressHydrationWarning(:36)と next-themes の ThemeProvider(:41)はセットになっていて、どちらかが欠けるとエラーになる。acgn-flow の ef73bb4 では一度に5件挙げられている。layout.tsx:109 の body に属性を追加、header 内で motion.span を素の span に置き換え、header は CSS の max-h/opacity で条件付きレンダリングを置き換え、auth UI に mounted ゲートを追加、さらに PageWrapper と FadeIn にも mounted を補った。ところが2分後に 46c6101 がまた settings-panel の next-themes mismatch を一箇所指摘していて、どうやらこれ系は漏れるものらしい捏…
静的サイトでも逃れられない。bbps の src/contexts/locale-context.tsx:96-104 では、effect の中で必ず localStorage を読み直す必要がある:
// 首次挂载:按偏好跳转(直接读 localStorage,避免 hydration 闭包捕获到服务端快照 'system')
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as LocalePref | null
// ...
}, [])根本的な原因は同じファイルの :74-76 にある getPrefServerSnapshot() が常に 'system' を返すことで、useSyncExternalStore の値は effect のクロージャに捕獲されてしまっており、そのまま使うと永遠にサーバースナップショットになってしまう。
さらにマニアックなのをもう一つ:home のときの 68e9570、static export がビルド時の年をフッターの HTML に焼き込んでしまい、クライアント側で年をまたぐと mismatch になるので、app-footer.tsx:38 に suppressHydrationWarning を載せるしかなかった。このサイトはその後まるごと Next から移行したので、あの属性の行はもう誰も見ていない…
罠その四:SSR 側で dayjs の locale が別のインスタンスに登録される
これは一番厄介で、なぜならクライアント側から見ると完全に正しいからだ~
acg-site の commit 99be0f1:副作用式の import "dayjs/locale/zh-cn" が Next サーバー側の ESM/CJS interop の下では、locale を業務コードとは別の dayjs インスタンスに登録してしまう。dayjs.locale("zh-cn") が見つからずに en にフォールバックし、その結果 SSR が吐き出す HTML は「16 天前」ではなく「16 days ago」になる。
修正は三重の保険:
// 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-next の src/app/template.tsx は全文14行で、コメントに framer-motion を使わない理由がはっきり書かれている:
framer-motion ではなく CSS アニメーション(tw-animate-css)を使う: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 が proxy に改名されたこと。vns-next の中のファイルはまさに src/proxy.ts で、エクスポートされる関数も proxy という名前で、リポジトリには src/middleware.ts なんて存在しない。ファイル先頭の :4 のコメントは「末尾スラッシュ自前管理リダイレクト(Next 16 の middleware は proxy に改名)」となっている。
もう一つは revalidateTag が今は第二引数(profile)を明示的に渡す必要があり、省略すると非推奨警告の経路を通る。これは別のプロジェクトで Next 16 にアップグレードしたときにぶつかったもので、同じリポジトリ内に二種類の書き方が共存していて、一方はオブジェクトを渡し、もう一方は文字列を渡している。どちらも合法で、ただスタイルが統一されていないだけなのだ。
とにかくまた踏む可能性が高いので、とりあえず書き残しておく…
