認証ミドルウェアが robots.txt をログインへリダイレクトする
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
認証ミドルウェアはパスの許可リストで公開・非公開を判定するため、robots.txt や sitemap.xml のような well-known ファイルを許可リストに入れ忘れると /login へリダイレクトされ、クローラは本来のファイルではなくログイン画面の本文を受け取る。
結論
未ログイン時に /login へ飛ばすミドルウェアは、robots.txt や sitemap.xml のような well-known ファイルも同じ扱いにしてしまいがちです。クローラは本来のファイルではなくログイン画面へのリダイレクトを受け取り、Lighthouse の SEO 監査が落ちます。
原因は2層に分かれています。
- ミドルウェアの許可リストに
robots.txt等が無い → 未認証扱いされて/loginへ 307 - ルートレイアウトの
noindexを、後から追加した公開ページが継承している → リダイレクトを止めても検索結果に出てこない
片方だけ直すと、もう片方の症状がそのまま残ります。
症状
社内向けの業務システムをベースに、認証不要で見られる公開ページ(申込フォームなど)を後から追加した構成で、Lighthouse 監査を回すと SEO スコアが大きく落ちます。
原因を辿ると、/robots.txt へのリクエストがミドルウェアの認証チェックに引っかかり、/login へ 307 でリダイレクトされていました。クローラーが受け取るのは robots のルールではなく、ログイン画面への誘導です。
同時に manifest.webmanifest も同じ扱いになっていました。こちらはブラウザが JSON として解釈しようとするため、返ってきたログイン画面の HTML がそのまま構文エラーになり、コンソールにエラーが出ます。Lighthouse の「コンソールにエラーが出ていないか」というチェック項目もここで失点します。
原因
1層目 — ミドルウェアの許可リストに well-known ファイルが無い
このアプリのミドルウェアは、パスを見て「未ログインでもアクセスしてよいか」を判定しています。判定はホワイトリスト方式で、公開ページのパスを列挙する形です。
const isPublic =
path.startsWith("/p/") ||
path === "/apply" ||
path.startsWith("/apply/") ||
path === "/terms" ||
// ...
一見すると網羅的に見えますが、ここには /robots.txt /sitemap.xml /manifest.webmanifest が入っていませんでした。これらはページとして意識されにくいファイルです。 開発者が「公開すべきパス」を洗い出すとき、実際にブラウザで開くページを列挙する発想になりがちで、クローラーやブラウザが暗黙に取りに来る well-known ファイルは列挙の対象から漏れます。
許可リストに無いパスへのリクエストは、認証チェックの分岐に落ちてそのまま /login へリダイレクトされます。
2層目 — ルートレイアウトの noindex を公開ページが継承する
このアプリはルートレイアウトで robots: { index: false, follow: false } を既定値にしていました。社内向けの業務システムが大半を占めるため、検索に載せたくないという判断自体は妥当です。
export const metadata: Metadata = {
// ...
robots: { index: false, follow: false },
};
問題は、後から追加した公開ページがこの既定値をそのまま継承する点です。Next.js のメタデータはネストしたレイアウトの値をマージしますが、robots を明示的に上書きしない限り、ルートの noindex がページにも効いたままになります。
1層目の 307 リダイレクトだけを直しても、robots.txt 自体は正しく返るようになりますが、noindex を継承した公開ページは robots のルール上は許可されていても検索結果には出てきません。2つの原因は独立していて、片方の修正がもう片方を直しません。
直す
1層目 — 許可リストに well-known ファイルを足す
const isPublic =
path === "/robots.txt" ||
path === "/sitemap.xml" ||
path === "/manifest.webmanifest" ||
path.startsWith("/p/") ||
path === "/apply" ||
// ...
あわせて robots.ts と sitemap.ts を明示的に用意し、公開してよいパスだけを許可し、認証必須のパスやトークンを含む URL は明示的に拒否します。
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: ["/apply", "/terms"],
disallow: "/",
},
sitemap: `${APP_URL}/sitemap.xml`,
};
}
disallow: "/" を先に書いておくことで、今後パスが増えても「明示的に許可したものだけが公開される」形になり、許可リストへの追加漏れが起きても外部への露出方向には倒れません。
2層目 — 公開ページ側で index を明示する
ルートレイアウトの noindex はそのまま残し、公開ページのメタデータ側で上書きします。
export const metadata: Metadata = {
title: "...",
description: "...",
robots: { index: true, follow: true },
};
この上書きは、公開ページを追加するたびに書く必要があります。 ルート側の既定値を index: true に倒すと、逆に社内向けページを検索に晒すリスクを負うため、既定値は非公開のまま個別に許可する方式を維持しています。
気づけなかった理由
どちらの原因も、通常のブラウジングでは気づきません。ログイン済みの開発者が /apply を開けばページは正しく表示されますし、/robots.txt を手で開いても、ブラウザがリダイレクトに黙って追従するため 307 が返っていること自体が見えません。
Lighthouse のような「未ログイン・リダイレクト非追従」の視点で見て初めて症状が出ます。 ミドルウェアの許可リストと、レイアウトの robots 継承は、どちらもコードレビューで一行ずつ見れば妥当に見える変更の積み重ねで、公開ページを1つ追加するたびに「well-known ファイルも通っているか」「noindex を上書きしたか」を都度確認する形にしない限り、同じ抜け方を繰り返します。
Next.js のビルド設定まわりで、変更が複数層に分かれていて1つ直しても次が出た話は Astro 7 でコンテンツコレクションが3箇所同時に壊れる にも書いています。
よくある質問
Q1robots.txt がリダイレクトされているかはどう確認しますか?
未ログイン状態で /robots.txt に直接アクセスし、レスポンスのステータスコードを見ます。200 でなく 307 や 302 が返り、Location ヘッダーが /login を指していれば該当します。ブラウザで開くと自動で追従してしまうため、curl -I のようにリダイレクトを追わない方法で確認する必要があります。
Q2matcher で静的ファイルを除外すれば直りますか?
それだけでは直りません。ミドルウェアの許可リストに robots.txt 等を追加するのは1層目の修正で、これで 307 は止まります。ただしアプリのルートレイアウトが noindex を既定にしている場合、公開ページ側にもインデックス許可を明示しないと、リダイレクトが止まっても検索結果には出てきません。
Q3ルートレイアウトを noindex にするのは間違った設計ですか?
間違いではありません。社内向けの業務システムなど、大半のページを検索に載せたくないアプリでは妥当な既定値です。問題は、後から公開ページを追加したときに noindex が継承されることを忘れやすい点にあります。公開ページ側で明示的に上書きする一手間が必要です。
Q4Lighthouse の SEO スコアが下がる原因はリダイレクトだけですか?
リダイレクト自体が robots.txt の不備として検出されるほか、manifest.webmanifest もミドルウェアに巻き込まれてログイン画面の HTML が返ると、ブラウザがそれを JSON として解釈しようとして構文エラーになります。これが Lighthouse のコンソールエラー検出にも引っかかり、SEO とは別の項目でもスコアを落とします。
この記事の根拠
- TypeScriptファイル 44〜74行目コミット 7ca4aab
- TypeScriptファイル 1〜20行目コミット 7ca4aab
- TypeScriptファイル 12〜25行目コミット 7ca4aab
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。