朝、ベッドの上で 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.ts で queryCollection('blog').select(...).all() の戻り値をそのまま useAsyncData の戻り値にしていた。見た目は普通の配列だが、内部に Vue の reactive proxy / @nuxt/content の内部 class instance / Symbol プロパティを抱えている。
Nuxt の payload reducer は、知らない class instance に出会うと null に落として書き出す。結果として _payload.json の該当 slot が null になり、CSR で data.value が null になり、null || [] のフォールバックで空配列にすり替わる。カレンダーは「今月の記事ゼロ」と判定して前月にフォールバックする。これがあの「一瞬出てから消える」の正体だった。
dev で再現しなかった罠
このバグの嫌らしさは、dev で完全に再現しないことだった。useAsyncData に getCachedData: 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 3 が null から 長さ1149の配列 に変わった。fromDom.currentMonth が「2026年 6月」になり、記事リンクが41本表示された。本番デプロイ後にも同じスクリプトを叩いて、log.eurekapu.com/blog で fromServer と fromDom が一致するのを確認した。
再発防止ルールを固定
このバグはこの半年で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.md と apps/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(前日の調査ログ)