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 になっていない」状態は、ブラウザでは気づけません。検索エンジンのクロールとアクセス解析にだけ静かに効きます。本記事では、redirect と rewrite がそれぞれ何を返すのか、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.js の applyRewriteToState に、明示的にこう書かれています。
// 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.7のcore/rewrites/handler.jsがstate.status = 200)。rewrite の前に status を立てても消える- 正解は
Astro.rewrite("/404")と、`404.astro` 側での `Astro.response.status = 404` の組み合わせ。rewrite 経由・未マッチ経由の両方を1箇所でカバーできる - rewrite は絶対パス指定でネストしたルートからも
/404を解決する - 検証は
curlでステータス 404・Location無しを確認する。ブラウザの見た目では判断しない