• #nuxt
  • #nuxt-content
  • #ssg
  • #hydration
  • #cloudflare-pages
  • #payload
  • #trouble-shooting
開発mdx-playground

朝、ベッドの上で iPhone から /blog を開いたら、6月のカレンダーが一瞬だけ視界に入って、まばたきの間に5月に切り替わった。記事リンクも27本から8本へ目減りした。前日のデプロイ後、何かをまた壊した気配がした。

積み残しの確認から始めたら、昨日中途半端に終わっていた blog-hydration-mismatch の計画書が真っ先に目に入った。「これお願いします」と Claude Code に投げた。

症状の最初の手触り

ブラウザで /blog を開く。一瞬だけ「2026年 6月」のカレンダーが見え、記事リンクが画面いっぱいに並ぶ。その直後、画面全体が点滅して「2026年 5月」に置き換わる。記事は8本まで減る。

view-source: で SSR の HTML を覗くと、ちゃんと6月のカレンダーと27本の記事リンクが含まれている。curl でも同じ結果が返る。サーバが返す HTML は正しい。壊れているのは hydration 後の DOM だけだった。

DevTools の Console には例の「Hydration completed but contains mismatches.」のエラーが出ていた。

切り分けの順序

dist の sql_dump.txt を疑った(過去にここが配信失敗で SQLite 初期化が壊れた事例があった)。本番の HTTP ステータスを叩いたら 200 OK で 7.5MB が返ってきた。配信路は健全。

WASM 初期化失敗を疑った。Console を見たが SQLite 関連のエラーは一切出ていない。これも違う。

次に _payload.json を直接 fetch して中身を覗いた。blog-public-articles の slot が null になっていた。SSR は配列を取れているのに、シリアライズ段階で null に化けている。これが決定打だった。

根本原因

apps/web/app/composables/useBlogArticles.tsqueryCollection('blog').select(...).all() の戻り値をそのまま useAsyncData の戻り値にしていた。見た目は普通の配列だが、内部に Vue の reactive proxy / @nuxt/content の内部 class instance / Symbol プロパティを抱えている。

Nuxt の payload reducer は、知らない class instance に出会うと null に落として書き出す。結果として _payload.json の該当 slot が null になり、CSR で data.valuenull になり、null || [] のフォールバックで空配列にすり替わる。カレンダーは「今月の記事ゼロ」と判定して前月にフォールバックする。これがあの「一瞬出てから消える」の正体だった。

dev で再現しなかった罠

このバグの嫌らしさは、dev で完全に再現しないことだった。useAsyncDatagetCachedData: getHydrationCachedData が刺さっていて、dev では SSR の値をそのままハイドレーション側のキャッシュに渡している。payload を経由しないので null 化も起きない。

pnpm dev でいくら触っても問題は出ない。pnpm generate してビルドした dist を wrangler pages dev dist で配信した瞬間に再現する。本番 (Cloudflare Pages Static) でも同じ。

「dev で動いてるから本番も動く」が嘘になる典型例だった。SSG + Static 配信は payload 経路を通る、という当たり前を体で覚え直した。

D 案: plain POJO 化を選んだ理由

対策は2つ候補が出た。

  • A 案: useState 切替: payload に乗せず、useState で状態管理する。mode 別 (public/unpublished) の state 分離が必要で、構造を作り変える話になる
  • D 案: plain POJO 化: useAsyncData のハンドラ末尾で .map して、フィールドを明示列挙したプレーンオブジェクトに詰め替える。class instance / reactive proxy を引き剥がす

A 案は構造が太る。しかも useState も結局 payload に乗るので、根本原因(class instance を return している)は同じ。D 案は12行追加するだけで原因を直接断てる。useAsyncData の API もそのまま使える。即 D 案を選んだ。

要点だけ書くと、こういう形に詰め替える。

return filtered.map(item => ({
  title: item.title,
  description: item.description,
  path: item.path,
  tags: Array.isArray(item.tags) ? [...item.tags] : item.tags,
  publishedAt: item.publishedAt,
  updatedAt: item.updatedAt,
  // 必要なフィールドだけ列挙する。`{...item}` だと隠れ proxy が残るので NG
}))

{...item} の shallow copy では Symbol プロパティと reactive proxy がそのまま残るので意味がない。JSON.parse(JSON.stringify()) は動くが型情報が壊れる。.map でフィールドを1つずつ列挙する のが一番安全だった。

検証

ローカルで pnpm test:run を走らせて 8/8 pass。pnpm generate でビルドして wrangler pages dev dist で本番相当を立てた。diagBlog() のスニペットを Console で叩く。

payload slot 3null から 長さ1149の配列 に変わった。fromDom.currentMonth が「2026年 6月」になり、記事リンクが41本表示された。本番デプロイ後にも同じスクリプトを叩いて、log.eurekapu.com/blogfromServerfromDom が一致するのを確認した。

再発防止ルールを固定

このバグはこの半年で2回目だった。前回も同じ罠で半日溶かしている。同じことを3回やるのは耐えがたいので、.claude/rules/nuxt-content-payload-null.md に恒久ルールとして書き込んだ。

  • 症状(一瞬出てから消える / payload slot が null)
  • 検出方法(DevTools Console で叩く diagBlog() スニペット)
  • 対策(D 案 plain POJO 化のテンプレ)
  • 再発防止チェックリスト(useAsyncData で外部由来の class instance を返していないか)
  • やってはいけないこと({...item} shallow copy / any で型を潰す)

モノレポルートの CLAUDE.mdapps/web/CLAUDE.md にもポインタを貼った。次に同じ症状を見たら、Claude Code がツリーを遡る過程でこのルールに当たるはずだ。

学び

「dev で動いてるから本番も動く」を信じない。SSG + Cloudflare Pages Static は payload 経路を通るので、デプロイ後にブラウザの DevTools Console で _payload.json の該当 slot まで目で確認する。ここを省くと、SSR の HTML が正しい分だけ問題に気づくのが遅れる。

そして queryCollection の戻り値を payload に乗せるときは、必ず .map で詰め替える。フィールドを1つずつ列挙する。手間は12行で済む。代わりに、半年後にまた半日溶かす未来を1つ消せる。

関連

  • .claude/rules/nuxt-content-payload-null.md(再発防止ルール)
  • apps/web/app/composables/useBlogArticles.ts(修正実装)
  • apps/web/scripts/verify-blog-payload.mjs(HTML 直接走査での検証スクリプト)
  • memo/2026-06-04/blog-hydration-mismatch.md(前日の調査ログ)