SPAの静的ページ配信でもCloudflare Workerを使おう!!!
Cloudflare Pagesでサブディレクトリ配置のSPAルーティングを実現しようとしたら詰みました。Worker Assetsへの移行で解決した話。
Papercalでは、ドメインを育てるためにLP・SPAアプリ・ブログなどを単一ドメインで提供しています。
/- Astro静的サイト(LP)/app- React SPA(アプリケーション本体)/blog- Astro静的サイト(ブログ)
Cloudflare Pagesでこれを実現しようとしたら、想像以上にハマりました。
結論を先に言うと、Cloudflare PagesでSPAをサブディレクトリに配置するのは現状かなり困難です。 最終的にWorker Assetsへの移行を選択しました。
問題の発生
ビルド時に静的サイトとSPAを同じディレクトリにマージしてデプロイ。構成はこうです。
dist/
├── index.html # Astro LP
├── blog/
│ └── index.html # Astro ブログ
└── app/
└── index.html # React SPA
一見うまくいきそうですが、問題が起きました。
/app/home に直接アクセスすると、LPが表示される。
/app にアクセスしてからナビゲーションすれば問題なく動きます。Service Workerが有効になればfallbackで正しく返されます。でも初回アクセスで /app/home を開くと、なぜかLPの index.html が返ってきます。
試したこと1: _redirects
Cloudflare Pagesのドキュメントによると、SPAのフォールバックは _redirects で設定できるはずです。
/app/* /app/index.html 200
結果: 動作せず。
調べてみると、Cloudflare Pagesには「SPAモード」という暗黙の動作があることがわかりました。404.html が存在しない場合、すべてのリクエストをルートの index.html にフォールバックします。
この自動SPAモードは _redirects より優先されるようで、/app/* へのリダイレクト設定は無視されて /index.html(LP)が返されてしまいました。
試したこと2: 404.htmlを配置
SPAモードを無効化するため、404.html を配置しました。
dist/
├── 404.html # 追加
├── index.html
└── app/
└── index.html
結果: 404が優先されすぎる。
今度は /app/home にアクセスすると 404.html が表示されるようになりました。_redirects の200 rewriteより、404.htmlの存在が優先されています。
これまでの結果を踏まえると、Cloudflare Pagesのルーティング優先順位は以下のようです。
- 静的ファイルの完全一致
404.htmlの存在チェック_redirectsのルール
404.html を配置すると、存在しないパスはすべて404として扱われ、_redirects の rewrite は適用されません。
試したこと3: Transform Rules
Cloudflare Transform Rulesを使えば、リクエストパスをリライトできます。Terraformで設定しました。
resource "cloudflare_ruleset" "spa_rewrite" {
zone_id = data.cloudflare_zone.main.zone_id
name = "SPA rewrite rules"
kind = "zone"
phase = "http_request_transform"
rules = [
{
expression = "(starts_with(http.request.uri.path, \"/app/\") and not http.request.uri.path contains \".\")"
action = "rewrite"
action_parameters = {
uri = {
path = {
value = "/app/index.html"
}
}
}
}
]
}
結果: URLが書き換わってしまう。
Transform RulesはHTTPリクエスト自体を書き換えます。つまり /app/home へのリクエストは /app/index.html として処理されます。
これの何が問題かというと、ブラウザのURLも /app/index.html として認識されることです。SPAのルーターは window.location.pathname を見てルーティングを決定しますが、Transform Rulesで書き換えられた場合、パスは /app/index.html になります。
結果として、どのURLにアクセスしても常に /app/ のルート画面(ログイン画面)が表示されるようになってしまいました。
なぜこうなるのか
Cloudflare Pagesは「ルートにSPAを配置する」ユースケースに最適化されています。
404.htmlがなければ自動でSPAモード- すべてのリクエストが
/index.htmlにフォールバック
サブディレクトリにSPAを配置するケースは想定されていないようです。
_redirects の200 rewriteは「このパスにはこのファイルを返せ」という指示ですが、Pagesの内部ロジックでは「ファイルが存在しない→404.htmlチェック」が先に評価されてしまいます。
Transform Rulesはリバースプロキシ層で動作するため、Pagesより手前でリクエストを書き換えます。しかしこれはHTTPレベルの書き換えなので、ブラウザから見たURLも変わってしまいます。
解決策: Worker Assetsへの移行
Cloudflareは「Workers Static Assets」という静的アセット配信機能を提供しています。これはPagesの後継というわけではありませんが、より柔軟なルーティングが可能です。
Worker Assetsでは、Workerコードでルーティングロジックを自分で書けます。
export default {
async fetch(request, env) {
const url = new URL(request.url);
// /app/* へのリクエストでファイルが存在しない場合
if (url.pathname.startsWith('/app/') && !url.pathname.includes('.')) {
// /app/index.html の内容を返すが、URLはそのまま
return env.ASSETS.fetch(new Request(new URL('/app/index.html', url.origin)));
}
return env.ASSETS.fetch(request);
}
};
これなら「リクエストURLは /app/home のまま、レスポンスは /app/index.html の内容」という動作が実現できます。
無料枠の懸念と最適化
Worker Assetsに移行すると、気になるのがWorkerの無料枠(100,000リクエスト/日)です。静的ファイル(CSS、JS、画像など)もすべてWorkerを経由すると、すぐに上限に達してしまいそうです。
しかし、wrangler.json の run_worker_first オプションを使えば、特定パスのみWorkerを経由させることができます。
{
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": ["/app/*"]
}
}
この設定により以下の動作になります。
/app/*へのリクエスト → Workerを経由(SPAフォールバック処理)/、/blog/*など → 静的アセットを直接配信(Workerリクエスト数にカウントされない)
これで、SPAのルーティングに必要な /app/* のリクエストだけがWorkerを使い、その他の静的ファイルは直接配信されます。
_headersとの併用に注意
ここで落とし穴があります。run_worker_firstを経由したリクエストには_headersファイルが適用されません。
つまり、/app/assets/* に Cache-Control: immutable を設定していても、Workerを経由すると無視されます。Viteがビルドするハッシュ付きアセットにimmutableキャッシュを効かせたい場合、これは困ります。
解決策は、negation pattern(!プレフィックス)で静的ファイルをWorkerから除外することです。
{
"assets": {
"run_worker_first": [
"/app/*",
"!/app/assets/*",
"!/app/images/*",
"!/app/*.*"
]
}
}
この設定で以下の動作になります。
/app/homeなど拡張子なしのパス → Workerを経由してSPAフォールバック/app/assets/*、/app/*.jsなどの静的ファイル → 直接配信(_headersのimmutableキャッシュが適用)
!/app/*.* で /app/ 直下の拡張子付きファイル(sw.js、favicon.icoなど)をまとめて除外できます。negation patternはpositive patternより優先されるため、/app/*にマッチしても除外パターンで除外されます。これにより、Workerを通過するリクエストはSPAルーティングが必要な拡張子なしパスのみに限定され、リクエスト数を大幅に削減できます。
404ページの設定
さらにWorkerリクエスト数を削減するため、not_found_handling オプションを設定します。
{
"assets": {
"not_found_handling": "404-page"
}
}
この設定により、dist/404.html が存在する場合、静的ファイルが見つからないときに404ページが表示されます。かつ、run_worker_first にマッチしないパスはWorkerを経由せず直接404を返します。
これがないと、存在しないパスへのリクエストもすべてWorkerに流れてしまい、無料枠を無駄に消費してしまいます。
結論
公式の推奨する通り、静的ページの配信でも素直にWorker Assetsを使いましょう!!!Pagesより柔軟で使いやすいです!!!
※ この記事の内容は2026年1月時点のものです。Cloudflareは頻繁にアップデートされるため、将来的に改善される可能性があります。