ブログ一覧

Astro SSR で「404 ページに飛ばす」がソフト404になる問題 — Astro.rewrite と Astro.response.status で正しく 404 を返す

動的ルート([slug].astro のようにパラメータでコンテンツを引くページ)で、URL に対応するエントリが見つからないとき「404 を返したい」というのはよくある要件です。素直に return Astro.redirect("/404") と書くと、一見それらしく 404 ページが表示されます。ところが実際に返っている HTTP ステータスは 404 ではありませんAstro.rewrite("/404") に変えても、今度は 200 が返ります。

この「404 のつもりが 404 になっていない」状態は、ブラウザでは気づけません。検索エンジンのクロールとアクセス解析にだけ静かに効きます。本記事では、redirectrewrite がそれぞれ何を返すのか、Astro が rewrite でステータスをどう扱うのか(機構)、そして正しく 404 を返す書き方をまとめます。

前提バージョン: 本記事のステータス挙動は Astro 7 系(ソースの引用は astro@7.0.7)で確認しています。動的ルートは on-demand rendering(SSR。output: "server" などサーバーレンダリング構成)が前提です。Astro 7 の SSR まわりでハマった別の落とし穴は「Astro 7 のエージェント検知が dev サーバの本当のエラーを隠す」にも書いています。

症状:404 のつもりが 404 になっていない

まず、よく書かれる2つの実装と、それぞれ実際に返るものを並べます。

  • return Astro.redirect("/404")302 + Location: /404(ブラウザが /404 に遷移する)
  • return Astro.rewrite("/404")200(URL は元のまま、本文は 404 ページ)

どちらも画面上は 404 ページが出るので、目視では正しく見えます。問題は返っているステータスコードの方です。

なぜ 404 でないと困るのか

ステータスが 404 でないことの実害は2つあります。

1. Google がソフト404扱いする。 ページが「見つかりません」という内容なのに HTTP ステータスが 200 で返ると、Google はこれを ソフト404 として扱います。ソフト404は「200 が返るが中身はエラー相当」の状態で、クロールバジェットを無駄に消費し、そのURLはインデックスされません。存在しない URL には正直に 404(または 410)を返すのが、クローラに対する正しい合図です。

2. アクセス解析で「どの URL が 404 だったか」が消える。 redirect 方式だと、解析ツール上のヒットは元の URL ではなく /404 への遷移として記録されます。つまり「どの URL で 404 が起きているか」という一番知りたい情報が /404 に集約されて潰れます。複数サイトを合算した実測では、週あたり1万件超が「/404 への直接アクセス」として積み上がり、リンク切れや旧 URL の当たりを追う手がかりが失われていました。404 を可視化して原因 URL を拾う話は「Google Analytics を使わず Cloudflare 側でアクセスを解析する」でも触れています。

Astro.redirect("/404") は 302 を返す

Astro.redirect() は名前のとおりリダイレクトです。デフォルトで 302 と Location ヘッダを返し、ブラウザ(やクローラ)を /404 へ遷移させます。遷移先の /404 ページ自体は普通のページなので 200 を返します。結果として、元の URL では 302、最終到達点では 200 となり、404 はどこにも現れません。加えて、遷移してしまうため元 URL の情報が解析から失われます。

存在しないコンテンツに対して「別ページへ移動させる」のは、そもそも意味的に 404 とは違う操作です。

Astro.rewrite("/404") は URL を保つが 200 になる

Astro.rewrite()rewrite(内部書き換え)で、ブラウザを遷移させずに元の URL を保ったまま別ルートの内容をレンダリングします。Location ヘッダも出ないので、解析上も元 URL が保たれます。ここまでは redirect より 404 に適しています。

ところが、rewrite するとステータスが 200 にリセットされますastro@7.0.7 のソースを追うと、rewrite の状態遷移を行う core/rewrites/handler.jsapplyRewriteToState に、明示的にこう書かれています。

// astro/dist/core/rewrites/handler.js
state.isRewriting = true;
state.status = 200;

rewrite に入った時点で state.status が 200 に上書きされます。つまり、動的ルート側で rewrite を呼ぶ前に `Astro.response.status = 404` を立てても、その値は rewrite で消えます。「rewrite にしたのに 200 のまま」なのはバグではなく、この機構による仕様どおりの挙動です。

正解:rewrite と、404 ページ側での status 設定を組み合わせる

解決策は二段構えです。

1. 動的ルート側では `Astro.rewrite("/404")` を返す(URL を保つため)。

// src/pages/blog/[slug].astro(フロントマター)
const { slug } = Astro.params;
const post = await getPostBySlug(slug);

if (!post) {
  return Astro.rewrite("/404");
}

2. `404.astro` 側で `Astro.response.status = 404` を立てる。

---
// src/pages/404.astro(フロントマター)
Astro.response.status = 404;
---
<!-- 404 ページの本文 -->

ポイントは、status を動的ルート側ではなく 404 ページ側で立てることです。理由は経路にあります。最終的に 404.astro がレンダリングされる経路は2つあります。

  • 動的ルートからの Astro.rewrite("/404") 経由
  • どのルートにもマッチしなかったときの、デフォルトの未マッチ 404 経由

rewrite はステータスを 200 にリセットしますが、その後で 404.astro のフロントマターが走ります。そこで Astro.response.status = 404 を立てれば、rewrite 経由・未マッチ経由のどちらでも同じ 404 ページで確実に 404 が返ります。動的ルートの数だけ status を書いて回る必要もなく、設定箇所が1つで済みます(Astro.response の詳細はレンダーコンテキストのリファレンスを参照)。

なお 404.astro の作り方自体は Astro 公式のカスタム404ページのとおりで、src/pages/404.astro を置くだけです。

絶対パス指定ならネストしたルートからも解決する

Astro.rewrite("/404") のように絶対パス(先頭 /)で指定すれば、/blog/[slug] のようにネストしたルートから呼んでも、ルート直下の /404 が解決されます。相対パスにすると呼び出し元の階層に引っ張られるので、404 ページのような「サイト共通の1枚」へ飛ばすときは絶対パスにしておくのが安全です。

検証:curl でステータスと Location を見る

ブラウザでは 404 ページが「見えて」しまうので、検証は必ずステータスコードとヘッダで行います。

# ステータスコードだけを見る
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4321/zzz-not-exist
# => 404

# ヘッダを見る(Location が無いこと、本文がカスタム404であること)
curl -sI http://localhost:4321/zzz-not-exist

確認するのは次の3点です。

  • ステータスが 404(200 でも 302 でもない)
  • Location ヘッダが無い(=リダイレクトしていない)
  • 本文がカスタム404ページの内容になっている

この方式を複数サイトに一括適用し、ローカルだけでなく本番でも 404 かつリダイレクト無しを確認しました。

まとめ

  • 動的ルートでエントリ未検出時に Astro.redirect("/404") すると 302。ブラウザは遷移し、元 URL は解析上 /404 に集約されて「どの URL が 404 か」が消える。Google からはソフト404扱いになる
  • Astro.rewrite("/404") は URL を保つが、ステータスは 200 にリセットされる(astro@7.0.7core/rewrites/handler.jsstate.status = 200)。rewrite のに status を立てても消える
  • 正解は Astro.rewrite("/404") と、`404.astro` 側での `Astro.response.status = 404` の組み合わせ。rewrite 経由・未マッチ経由の両方を1箇所でカバーできる
  • rewrite は絶対パス指定でネストしたルートからも /404 を解決する
  • 検証は curl でステータス 404・Location 無しを確認する。ブラウザの見た目では判断しない

参考リンク