ryuhei373.dev

Nuxt Content v3 と Cloudflare Workers の trailing slash 問題

このサイトで記事詳細ページに直接アクセスすると、hydration mismatch が起きたり、title 要素が undefined になったりする問題が発生していた。

結構前からこの問題は認識していたのだが、対処法がわからず、そもそもアクセスが多いサイトでもないため放置してしまっていた。

結果的に Cloudflare Workers の設定で解決できたのだが、過程で遠回りな対応もしていたため、以下に原因と対応をまとめる。

現象

記事詳細ページ(例えば今表示しているこのページ)へのアクセス方法によって、記事の取得結果が異なるという現象が発生していた。

  • トップページから記事をクリックして遷移した場合、記事の内容やメタデータの取得が正常に行われる
  • 直接記事詳細の URL にアクセスした場合、queryCollection('blog').path(path).first() で取得する記事データが undefined になり、hydration mismatch エラーが発生

原因

原因は Cloudflare Workers Static Assets のデフォルト設定と Nuxt Content のパス形式の不一致だった。

Nuxt Content は記事を /blog/2025-10-08 のように trailing slash なしで管理しているが、Cloudflare Workers Static Assets には html_handling という設定があり、デフォルトは auto-trailing-slash になっている。

この設定では、ディレクトリ型の URL(/blog/2025-10-08/index.html のように生成されるもの)に対して trailing slash を追加する挙動になる。

そのため、直接 URL にアクセスした場合、Cloudflare のリダイレクトが発生し、最終的に useRoute().path/blog/2025-10-08/ を返してしまう。これが Nuxt Content のパス(/blog/2025-10-08)と一致しないため、queryCollection が記事を見つけられなかった。

最初の対応

最初は、アプリケーション側で trailing slash を除去する処理を追加した。

app/pages/[...slug].vue
<script setup lang="ts">
const route = useRoute();
const path = route.path.replace(/\/$/, ''); // これを追加
const { data: article } = await useAsyncData(path, () =>
  queryCollection('blog').path(path).first()
);
</script>

これで trailing slash ありの URL にアクセスした場合も、正しいパスで記事を取得できるようになった。

しかし、トップページから遷移した場合は trailing slash なし、検索エンジンから直接アクセスした場合は trailing slash ありの URL になってしまうため、根本的な解決とは言えなかった。

根本的な解決

その後、「Cloudflareがリダイレクトしてるんだから、Cloudflare側に何か設定があるだろ」と調べていったところ、Cloudflare Workers Static Assets には html_handling という設定があり、trailing slash の挙動を制御できることがわかった。

HTML handling

How to configure a HTML handling and trailing slashes for the static assets of your Worker.

developers.cloudflare.com favicondevelopers.cloudflare.com
HTML handling

上記を参考に、wrangler.jsonchtml_handling: "drop-trailing-slash" を追加したところ、どの経路からアクセスしても useRoute().path が Nuxt Content のパス形式と一致するようになり、アプリケーション側の trailing slash 除去処理は不要になった。

最後に

冒頭で「そもそもアクセスが多いサイトでもないため放置」と書いてはいたが、実際のところ検索エンジンからの流入、つまり記事に直接アクセスされることもゼロではなかったので解決できて安心した。