サイトを運用していると、ページを消す日が必ず来る。薄いタグページの整理、データの誤りの削除、機能ごとの廃止。そのとき消したURLに何を返すかで、その後の数ヶ月が変わる。
結論から書く。移転先があるなら301、無いなら410 Gone。404で放置してはいけない。 そして 静的サイトでも410は返せる。ただし Astro の middleware では返せない。ここが一番のハマりどころだった。
「404にしておけばGoogleが諦める」は誤り
「消したページは404になるのだから、Googleもそのうち忘れる」。よく聞く話だし、そう考えるのは自然だ。
実測したら、違った。
Search Console API で、過去16か月に一度でも検索結果に表示されたURLを全件(2,459件。うちステータスを測れたのは2,456件)取り出し、全部に実際にHTTPリクエストを投げて現在のステータスを調べた。そのうえで代表URLをURL検査にかけて、Google側の認識を見た。
410を返しているURLは、こうなっていた。
判定 : NEUTRAL (URL が Google に認識されていません)
最終クロール: -
Googleの記録から完全に消えている。 最終クロール日すら残っていない。
一方、404のまま放置していたURLはこうだった。
判定 : NEUTRAL (クロール済み - インデックス未登録)
最終クロール: 2026-03-30
robots.txt : ALLOWED
URLを保持したまま、インデックスに載せずにクロールし続けている。 最終クロールは半年近く前だが、記録からは消えていない。
この差は数字にも出ていた。実測した2,456URLの内訳は、410が1,576件、200が529件、404が329件。410にしたものは軒並み「認識されていません」に到達していて、404のまま残っていた329件は全部「クロール済み - インデックス未登録」だった。
何が困るかというと、クロール予算だ。Googleが1つのサイトに割くクロールの量には上限がある。死んだURLを何千件も再クロールし続けている間、その予算は生きているページに回らない。新しく書いた記事がなかなかインデックスされない、という形で跳ね返ってくる。
うちの場合、Search Consoleの「見つかりませんでした(404)」は9,646件あった。1万件近い死骸を抱えたまま、新規ページのインデックスが遅いと嘆いていたわけだ。そもそもなぜそれだけの死骸を積むことになったのか、ドメインごと評価を落とされるまでの経緯は別の記事に書いた。この記事は、その後始末の実装の話だ。
どこで404が湧くのか
意外だったのは、404が湧く場所が「記事を消したとき」だけではなかったことだ。
実際に棚卸ししたら、上位はこうなった。
| 件数 | プレフィックス | 何をしたか |
|---|---|---|
| 169 | /start/*/len/*/ | しりとりの文字数絞り込みURL面 |
| 104 | /tag/* | 絵文字のタグページ |
| 20 | /end/*/len/*/ | 同上 |
| 11 | /tags/* | ブログの薄いタグ |
| 6 | /colors/* | 色データの削除 |
| 4 | /number/* | データ点検で消した誤りデータ |
つまり サイトの品質改善でやる「間引き」が、そのまま404の生産工程になっている。薄いページを消すのは正しい判断なのに、その後始末をしないと別の問題に化ける。
2026年8月に絵文字サイトのタグページを「2絵文字以上のタグ」に限定したときは、ビルド出力が15,317ファイルから8,534ファイルに減った。差分の約6,800URLが、そのまま404になって残った。しりとり側でも辞書の入れ替えで約3,700URLが同じ状態になっていた。どちらも「整理した」つもりで、実際には「死骸を1万件積んだ」のだった。
301ではなく410を選ぶ理由
移転先が1対1で対応するなら301でいい。迷うのは「対応する移転先が無い」ときだ。
このとき、とりあえずトップページや一覧ページに301する手はある。でもこれは勧めない。リダイレクト先が内容的に対応していないと、Googleはソフト404として扱う。301したのに評価されず、しかも「リダイレクトがある」ぶん処理が増える。404より悪くなることさえある。
410は「意図的かつ恒久的に削除した」という明示だ。曖昧さが無いぶん、再クロールの打ち切りが一番速い。実測でもそうなった。
ちなみに301は取り消せないという別の怖さもある。ブラウザは恒久リダイレクトを永続的にキャッシュするので、暫定の入口に301を置くと後から戻せなくなる。迷ったら410のほうが安全だ。
本題: Astroのmiddlewareでは410を返せない
さて実装だ。Astroには middleware がある。素直に考えれば、middlewareで対象のパスを判定して410を返せばいい。
// これは本番で動かない
export const onRequest = async (context, next) => {
if (context.url.pathname.startsWith("/tag/")) {
return new Response(null, { status: 410 });
}
return next();
};
ローカルの astro dev では、これが完璧に動く。410が返る。テストも通る。安心してデプロイする。そして本番では404が返り続ける。
理由はこうだ。どのルートにもマッチしないリクエストは、事前レンダリング済みの404.htmlに直接フォールバックし、middlewareを通らない。
そして厄介なのは、開発サーバーではこの差が出ないことだ。astro dev は全部をSSRで捌くので、未マッチのリクエストもちゃんとmiddlewareを通る。本番のCloudflare上でだけ、静的な404.htmlが先に返る。ローカルで完全に動いているものが本番でだけ動かないので、切り分けが難しい。
SSRを使っているサイトなら、404.astro に書いてある prerender = true を外してSSR化すれば直る。未マッチURLが404.astroのSSRルートに入るようになり、middlewareを通るようになるからだ。
問題は output: 'static' の完全な静的サイトだ。こちらはそもそもSSRのランタイムが無いので、middlewareという逃げ道自体が無い。
静的サイトでも410は返せる(0.6KiBのWorker)
Cloudflare Workers の静的アセット配信には run_worker_first という設定がある。指定したプレフィックスのリクエストだけWorkerを先に通し、それ以外はアセットを直接配信するというものだ。
これを使うと、こう書ける。
const GONE_PREFIX = "/tag/";
export default {
async fetch(request, env) {
const res = await env.ASSETS.fetch(request);
if (res.status !== 404) return res;
const { pathname } = new URL(request.url);
if (pathname === "/tag" || pathname.startsWith(GONE_PREFIX)) {
// 本文は404ページのものを流用する(利用者には同じ見た目)。
// ステータスだけ410にして、クローラーに恒久的な削除であることを伝える。
return new Response(res.body, {
status: 410,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "public, max-age=3600",
},
});
}
return res;
},
};
wrangler.toml 側はこうだ。
main = "worker/index.js"
[assets]
directory = "dist"
not_found_handling = "404-page"
binding = "ASSETS"
# /tag/ 以外はWorkerを通さずアセットを直接配信する(呼び出し数を増やさない)
run_worker_first = ["/tag", "/tag/*"]
これでビルドされるWorkerは0.6KiBほど。run_worker_first を絞ってあるので、サイトの大半のリクエストはWorkerを通らず、Workersの呼び出し数もほとんど増えない。従量課金を気にする個人サイトにはここが効く。
ポイントは、まずアセットに問い合わせて、404が返ってきたときだけ410に書き換えるところだ。存在するタグページ(2絵文字以上のもの)はそのまま200で配信される。生きているページと死んだページの一覧をWorkerに持たせる必要がない。
「本文はそのまま、ステータスだけ410」が効く場面
もうひとつ、意外と使える形がある。意図的に404を返しつつ、クライアント側で中身を描画しているページだ。
うちのしりとりサイトには、/start/か/end/く/ のような組み合わせ検索がある。組み合わせは無限に増えるので静的ページとしては焼かず、404.astroに来たリクエストをJavaScriptが受け取って、クライアント側で結果を描いている。ステータスを404にしてあったのは「検索エンジンには載せたくない」という意図だった。
これが狙い通りに機能していなかった。Googleは404のURLを忘れないし、サイト内リンクから次々に発見して再クロールし続ける。実測でもきっちり「クロール済み - インデックス未登録」として保持されていた。
ここで効くのが、本文をそのまま返してステータスだけ410にするという手だ。
return new Response(res.body, { status: 410, ... });
利用者から見た挙動は何ひとつ変わらない。JavaScriptは普通に動いて検索結果が描画される。変わるのはHTTPステータスだけで、クローラーだけが「ここは恒久的に消えた」と理解して立ち去る。もともとインデックスさせたくないページなので、失うものもない。
効果の確かめ方
デプロイしたら、まず curl -sI でステータスを見る。ここでリダイレクトが302に見えたり、想定と違うことがあるので、実装とレスポンスの両方で裏を取っておくといい。
そのうえで、Google側の認識はURL検査で見る。Search Console APIでも取れる。
410が効いたURLは、しばらくすると 「URL が Google に認識されていません」 に変わる。ここまで行けば、そのURLはGoogleの記録から消えたということだ。逆に「クロール済み - インデックス未登録」のままなら、まだ保持されている。
即座には変わらない。うちの場合、2026年7月2日に410を返し始めたURL群が「認識されていません」になっているのを確認したのは9月7日で、その間は追っていない。数週間から2か月は見ておいたほうがいい。焦って301に変えたりしないこと。
数字としては、Search Consoleの「見つかりませんでした(404)」が9,646件から9,284件へ、「クロール済み - インデックス未登録」が16,646件(8月6日のピーク)から14,774件へと、どちらも減少に転じた。1か月で1,872件の減少なので、ペースは速くない。ただ、増え続けていたものが減り始めたという事実のほうが大事だ。
まとめ
- 消したURLは404で放置しない。 移転先があるなら301、無いなら410
- 実測すると、410は「URLがGoogleに認識されていません」まで到達し、404は半年経っても「クロール済み - インデックス未登録」として保持され続ける
- 404の死骸はクロール予算を食う。新規ページがインデックスされない原因になる
- Astroのmiddlewareでは410を返せない。 未マッチのリクエストは事前レンダリング済みの404.htmlにフォールバックしてmiddlewareを通らない。しかも
astro devでは全部SSRなので再現しない - SSRサイトなら404.astroの
prerenderを外す。静的サイトならrun_worker_firstで対象プレフィックスだけを通す最小Workerを置く - 本文をそのまま返してステータスだけ410にできるので、利用者の体験を変えずにクローラーだけ止められる
- 効果の確認はURL検査。2か月単位で見る
薄いページを整理するのは正しい。ただ、消した後始末までが「整理」だ。私は1万件の死骸を積んでからそれを知った。