SPA デプロイ後に発生する Cannot GET エラーの解決方法
デプロイ後のCannot GETとSPAの404エラーを、Nginx、Apache、Netlify、Vercel、S3 CloudFrontのサーバー書き換えで修正します。
シングルページアプリをデプロイした後に発生する「Cannot GET /route」や 404 エラーは、ルーティングのバグではなく、たいていサーバー設定の問題です。解決策は、実際のファイルに一致しないリクエストパスに対して、サーバーが index.html を返すようにすることです。
よくあるパターンです。ビルドが出荷され、クリックして回れば全ページが動作するのに、誰かが /dashboard をリフレッシュしたり、共有された /orders/42 へのリンクを開いたりすると、素っ気ない 404 が返ってくる。多くの場合、ルーターにも問題はなく、ビルドにも問題はありません。存在しないファイルをサーバーに要求してしまっただけです。
この記事では、なぜこのエラーがハードナビゲーションでのみ発生するのかを説明し、その上で解決策と、Nginx、Apache、Netlify、Vercel、そして CloudFront 配下の S3 における設定、さらにその後に対処すべき副作用について解説します。
重要なポイント
- リフレッシュ時の SPA の 404 は、リクエストがサーバーに到達し、サーバーがそのパスに実在するファイルを探した結果、ルートにある
index.htmlしか見つからないために発生する。 - ローカルの開発サーバーは、一致しないパスに対してすでに
index.htmlへフォールバックしているため、このバグを隠してしまう。 - 解決策はリダイレクトではなくリライトである。ステータス 200 で
index.htmlを返し、URL をそのまま維持してルーターが読み取れるようにする。 - S3 では、エラードキュメントによる方法だと 404 ステータスが維持されてしまう。403 と 404 の両方を
/index.htmlにマッピングする CloudFront のカスタムエラーレスポンスを設定して初めて 200 が返る。 - キャッチオールのフォールバックを設けると、不正な URL でも 200 が返るようになるため、アプリ側に not-found ビューを描画するワイルドカードルートが必要になる。
Cannot GET エラーはいつ発生するのか?
このエラーはハードナビゲーション時にのみ発生します。ページのリフレッシュ、アドレスバーへの URL 直接入力、あるいは共有されたディープリンクを新しいタブで開いた場合です。アプリ内のナビゲーションは問題なく動作します。アプリが一度読み込まれれば、ルーターはサーバーと通信することなくブラウザ内だけでビューを切り替えるからです。正確なメッセージはホストによって異なり、Express ベースのサーバーは「Cannot GET /route」と表示し、静的ホストは各自の 404 ページを返します。
これはこのバグが QA をすり抜ける理由でもあります。デプロイ直後の SPA のセッションリプレイを見ると、破綻はハードナビゲーション(リフレッシュや外部から開かれたリンク)で起きており、アプリ内のクリック操作中には決して起きません。そのため、稼働中のアプリをクリックして回るだけのテストはきれいに通過し、実際のユーザーが 404 に突き当たることになります。
なぜリフレッシュ時の SPA の 404 はサーバーの問題なのか?
静的サーバーは各リクエストパスをディスク上のファイルにマッピングします。SPA のビルドが生成するのは 1 つの HTML ファイル index.html と JS・CSS アセットだけなので、/dashboard への直接リクエストはそのパスにファイルを見つけられず、サーバーは正しく 404 を返します。React Router、Vue Router、シングルページアプリとして構成された SvelteKit のいずれも、まったく同じようにこの問題に突き当たります。フレームワークは無関係であり、ルートはまだ読み込まれていない JavaScript の中にしか存在しないからです。
ローカル開発でこのエラーが現れないのは、ほとんどの SPA 開発サーバーがフォールバックを有効にした状態で提供されているためです。ファイルに一致しないパスには自動的に index.html が返されます。ローカル環境は、本番サーバーがやっていないことを黙ってやってくれていたのです。
Cannot GET エラーの解決策とは?
既存のファイルに一致しないリクエストパスに対して index.html を返すようサーバーを設定します。そうすればアプリが読み込まれ、そのルーターがその URL に対応するビューを描画します。これはリダイレクトではなく、ステータス 200 で index.html を返すリライトでなければなりません。リダイレクトではアドレスバーの URL が変わってしまい、ルーターは元のパスがそのまま残っている必要があるからです。
| ホスト | 設定の場所 | 仕組み |
|---|---|---|
| Nginx | server ブロック | try_files |
| Apache | vhost または .htaccess | FallbackResource |
| Netlify | _redirects または netlify.toml | ステータス 200 のリライトルール |
| Vercel | vercel.json | rewrites 配列 |
| S3 + CloudFront | バケットのウェブサイト設定 + ディストリビューション | エラードキュメント + カスタムエラーレスポンス |
サーバーにまったく手を出せない場合は、ハッシュベースのルーティングでこれらすべてを回避できます(フラグメントはブラウザの外に出ないため)。ただし、すべての URL が恒久的に /#/about の形になってしまうので、最後の手段と考えてください。
Nginx と Apache
Nginx と Apache では、SPA のフォールバックをサーバー設定の 1 つのディレクティブで表現します。Nginx の場合は、ルートの location に try_files フォールバックを追加します。リクエストパスをまずファイルとして、次にディレクトリとして探し、どちらも見つからなければ内部的に /index.html をステータス 200 で返します。
server {
listen 80;
root /var/www/app/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
Apache では、mod_dir の 1 つのディレクティブが同じ役割を果たします。実在するファイルはそのまま配信され、それ以外はすべてフォールバックに流れます。
FallbackResource /index.html
アプリがサブパス配下にある場合は、それも含めます: FallbackResource /app/index.html。従来の mod_rewrite による同等の設定も .htaccess で引き続き機能します。
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
Netlify と Vercel
Netlify では、ステータス 200 を指定したリダイレクトルールがリライトになります。ブラウザには訪問者が要求したパスがそのまま表示され続け、レスポンスとして index.html の内容が返ります。1 行の _redirects ファイルを追加するか、
/* /index.html 200
netlify.toml に同等の設定を書きます。
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
_redirects ファイルは publish ディレクトリ内に配置される必要があるため、ビルドが出力フォルダにコピーするようにしてください。netlify.toml はリポジトリのルートに置きます。splat ルールは、実際のファイルが存在するパスを乗っ取ることはないので、JS や CSS アセットは引き続き読み込まれます。
Vercel の場合は、vercel.json に rewrites エントリを追加します。
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
destination には / ではなく明示的に /index.html を指定するのが望ましいです。Vercel ではどちらも同じファイルに解決されますが、明示的な形式は実際に配信されるものを示しており、他のあらゆるホストにも通じるメンタルモデルになります。ただし例外が 1 つあります。cleanUrls: true を設定している場合、destination に .html 拡張子を含めることはできず、Vercel は index.html をサイトルートにマッピングするため、destination は / に設定します。
S3 と CloudFront
S3 で SPA の 404 を修正するには 2 つの設定が必要です。バケットの設定だけではエラーステータスが維持されてしまうからです。S3 の静的ウェブサイトホスティングでは、index.html をインデックスドキュメントとエラードキュメントの両方に設定します。
aws s3 website s3://your-bucket \
--index-document index.html \
--error-document index.html
これにより未知のパスに対してアプリが配信されますが、エラーステータスは維持されます。ブラウザは 404 コードとともに index.html を受け取ります。200 を返すには、403 と 404 の両方を /index.html にマッピングしてレスポンスコード 200 を返す CloudFront のカスタムエラーレスポンスを追加します。403 のマッピングが重要なのは、S3 の REST エンドポイントをオリジンとするディストリビューションでは、存在しないキーに対して 404 ではなく 403 Access Denied が返るためです。Terraform で書くとこうなります。
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
落とし穴が 1 つあります。カスタムエラーレスポンスはディストリビューション全体に適用されるため、同じディストリビューション経由で /api/* をプロキシしている場合、API の 403 や 404 も index.html として返ってきてしまいます。
代償: 本物の 404 が消える
キャッチオールのフォールバックには 1 つの代償があります。本当に間違っている URL も、実際の 404 ではなくステータス 200 で index.html を返すようになるのです。サーバーは /orders/42 と /ordersss/42 を区別できなくなるため、アプリ側で not-found ビューを描画するワイルドカードルートを定義しなければなりません。どのルーターにもそのための書き方があり、React Router では次のようになります。
<Route path="*" element={<NotFound />} />
これはクライアントで描画される 404 であり、HTTP ステータスは依然として 200 であることに注意してください。クローラーがそれらのページをどう分類するかを気にする場合には、これが問題になります。
まとめ
リフレッシュ時の 404 は、静的サーバーが本来やるべきことをやっているだけであり、解決策は使っているホストの方言で 1 つのルールを適用することです。すなわち、ファイルに一致しないすべてのパスを、ステータス 200 で index.html にリライトする。自分のホスト向けのスニペットを追加し、再デプロイし、ディープルートをハードリフレッシュして確認したうえで、ワイルドカードの not-found ルートを追加して、不正な URL でもユーザーに迷子であることを伝えられるようにしましょう。
FAQ
SPA のフォールバックによる解決策は GitHub Pages でも使えますか?
使えません。GitHub Pages はサーバーサイドのリライトをサポートしていないため、index.html へのフォールバックルールを設定する方法がありません。標準的な回避策は、リクエストされたパスを保持したまま index.html にリダイレクトするスクリプトを含むカスタム 404.html ページを用意し、読み込み後にルーターがそのパスを復元する方法です。ただし GitHub はそのページを 404 ステータスで配信し続けます。もう 1 つの選択肢はハッシュベースのルーティングで、これならルートがサーバーに送信されることはありません。
Next.js や Nuxt のようなサーバーレンダリングのフレームワークでもこの問題は起きますか?
自前のサーバーで動作している場合は起きません。サーバーレンダリングのフレームワークはすべてのルートをサーバー側で処理するため、リフレッシュやディープリンクでもレンダリング済みの HTML が直接返されます。リフレッシュ時の 404 問題は、ルートがクライアントサイドの JavaScript の中にしか存在しない静的なシングルページビルドにのみ影響します。ただし、これらのフレームワークから静的エクスポートしたアプリでは、リクエストされたルートに対応する事前レンダリング済みの HTML ファイルがディスク上に存在しない場合、同じ問題が起こり得ます。
すべてのパスを index.html にリライトすると JS や CSS のアセットが壊れませんか?
壊れません。どの仕組みもフォールバックする前に実在するファイルを探します。Nginx の try_files はまずリクエスト URI を試し、Apache の FallbackResource は実在するファイルへのリクエストには手を出さず、Netlify の splat リライトも 200! で強制しない限り既存のパスを乗っ取りません。フォールバックを追加した後もアセットの読み込みに失敗する場合、通常の原因はネストされたルート配下で相対アセットパスが解決されてしまうことです。この場合、ブラウザは間違ったディレクトリからアセットを要求し、代わりに index.html を受け取ります。
index.html をステータス 200 で配信すると SEO に悪影響がありますか?
あり得ます。存在しない URL がステータス 200 で not-found の内容を返すと、ステータスコードでは実在するページと不正な URL を区別できなくなるため、Google はそれを「ソフト 404」と分類してインデックスから外すことがあります。対象のルートで検索インデックスが重要な場合は、事前レンダリングやサーバーサイドレンダリングによってルートごとの正しいステータスコードを取り戻せます。ログインの背後にあるアプリであれば、クローラーがそれらのルートを見ることはないため、このトレードオフは無関係です。