• #nuxt
  • #nuxt-content
  • #cloudflare-pages
  • #ssg
  • #troubleshooting
開発未分類メモ

Nuxt Content v3 + Cloudflare Pages で直リンク時に404になる問題の解決

結論

Cloudflare Pagesが /152/152/ にリダイレクトするため、useAsyncData のキーがプレレンダリング時(page-/152)とクライアント時(page-/152/)で食い違っていた。

障害の因果関係を整理すると、次の流れになる。

  1. Cloudflare Pagesが /152/152/ に 301リダイレクト
  2. クライアント側の route.path/152/ に変わる
  3. useAsyncData のキーが page-/152/ になる(プレレンダリング時は page-/152
  4. キー不一致でペイロードが見つからず、コンテンツを再クエリ → 失敗して null
  5. page.valuenull → 404エラー表示

permalinkの末尾スラッシュを正規化し、getCachedData でプレレンダリング済みペイロードを優先させることで解決した。

環境

  • Nuxt 3.21.1
  • @nuxt/content v3
  • Nitroプリセット: cloudflare-pages-static(SSG)
  • デプロイ先: Cloudflare Pages

症状

  • トップページ(/)から記事リンクをクリック → 正常に表示される
  • ブラウザのURLバーに /152 を直接入力 → 404 Page Not Found
  • curl で同じURLを叩くと 200 OK で正しいHTMLが返る

サーバーは正常に配信しているのに、クライアント側のハイドレーションで404になっていた。

調査の経過

1. プレレンダリングは正常

dist/152/index.html は存在しており、全168記事が静的生成されていた。_redirects ファイルの /* /404.html 404 は、静的ファイルがない場合のフォールバックなので問題なし。

2. ハイドレーションミスマッチ

Chrome DevToolsのコンソールに以下のエラーが出ていた。

Hydration completed but contains mismatches.

正しくレンダリングされたHTMLが、クライアント側のハイドレーションで上書きされ、404表示になっていた。

3. インラインペイロードの調査

fetchで取得したHTMLの末尾にあるインラインペイロードを確認した。

{"page-/152": -1}

_payload.json には正しいデータが入っていた。-1 はNuxt内部のエンコード値であり、公開APIではないため意味は断定できない。重要なのはキーの一致・不一致の方だった。

4. 真の原因: トレイリングスラッシュの不一致

window.__NUXT__.data を確認したところ、決定的な証拠が見つかった。

{
  "page-/152": "HAS DATA",   // プレレンダリング時に生成
  "page-/152/": null          // クライアント側で生成
}

Cloudflare Pagesは /152 へのリクエストを /152/ にリダイレクトする。クライアント側で route.path/152/ になるため、useAsyncData のキーも page-/152/ になる。プレレンダリング済みデータは page-/152 に格納されており、キーが一致せずデータを取得できなかった。

5. 否定した仮説: D1データベース

ビルド時に以下の警告が出ていたため、当初はこちらを疑った。

@nuxt/content WARN Deploying to Cloudflare requires using D1 database, switching to D1 database with binding DB.

別プロジェクトで同じ警告が出ており、database.type: 'sqlite'sqliteConnector: 'native' の設定で解決していたため追加した。しかし今回はこれだけでは解決せず、主因はトレイリングスラッシュの不一致だった。

解決策

[...slug].vue に3つの修正を加えた。このプロジェクトでは permalink という独自のfrontmatterフィールドでルーティングしており、queryCollection.where() を使っている。Nuxt Content標準の .path() を使う場合も、同じ考え方で route.path を正規化すればよい。

<script setup lang="ts">
const route = useRoute()
// 修正1: 末尾スラッシュを正規化
const rawPermalink = '/' + (Array.isArray(route.params.slug)
  ? route.params.slug.join('/') : route.params.slug)
const permalink = rawPermalink.replace(/\/+$/, '') || '/'

// 修正2: getCachedData でプレレンダリング済みペイロードを優先
const { data: page } = await useAsyncData(`page-${permalink}`, () =>
  queryCollection('pages')
    .where('permalink', '=', permalink)
    .first(),
  {
    getCachedData: (key, nuxtApp) =>
      nuxtApp.payload?.data?.[key] ?? nuxtApp.static?.data?.[key],
  }
)

// 修正3: 404チェックをサーバー側のみに限定
if (!page.value && import.meta.server) {
  throw createError({ statusCode: 404, statusMessage: 'Page Not Found' })
}
</script>

各修正のポイント

修正内容理由
末尾スラッシュ正規化rawPermalink.replace(/\/+$/, '')Cloudflareのリダイレクトでキーが変わる問題を防ぐ
getCachedDataペイロードから直接データを取得(?. でnull安全)クライアント側でDBを再クエリせず、プレレンダリング結果を使う
import.meta.server ガード404 throwをサーバー限定にSSGではHTMLが正しく生成済みなので、クライアント側で404を投げる必要がない

確認チェックリスト

修正後に以下を確認する。

  • URL直打ち(/152)で記事が表示される
  • トップページからのリンククリックで記事が表示される
  • 存在しないURL(/99999)でプレレンダリング時に404が返る
  • window.__NUXT__.data のキーに末尾スラッシュが含まれていない

別件メモ

  • Amazonウィジェット画像: ws-fe.amazon-adsystem.com/widgets/q?... は廃止済み。テキストリンクに置換した
  • Wayback Machine CDX API: favicon画像の正しいパスを探すのに cdx/search/cdx?url=...&filter=mimetype:image.* が便利だった
  • ERR_QUIC_PROTOCOL_ERROR: カスタムドメインでのブラウザ表示エラー。curl では正常なため、ブラウザのQUICキャッシュの問題。シークレットウィンドウで回避できた

ERR_QUIC_PROTOCOL_ERROR の画面

まとめ

Cloudflare Pages + Nuxt Content v3のSSGで直リンク時に404になる問題は、トレイリングスラッシュによる useAsyncData のキー不一致が原因だった。Cloudflare Pagesが末尾スラッシュを付与してリダイレクトするため、ルートコンポーネントでのパス正規化が必要になる。