Nuxt Content v3 + Cloudflare Pages で直リンク時に404になる問題の解決
結論
Cloudflare Pagesが /152 を /152/ にリダイレクトするため、useAsyncData のキーがプレレンダリング時(page-/152)とクライアント時(page-/152/)で食い違っていた。
障害の因果関係を整理すると、次の流れになる。
- Cloudflare Pagesが
/152→/152/に 301リダイレクト - クライアント側の
route.pathが/152/に変わる useAsyncDataのキーがpage-/152/になる(プレレンダリング時はpage-/152)- キー不一致でペイロードが見つからず、コンテンツを再クエリ → 失敗して
null page.valueがnull→ 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キャッシュの問題。シークレットウィンドウで回避できた

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