記事一覧に戻る
SPAの静的ページ配信でもCloudflare Workerを使おう!!!

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のルーティング優先順位は以下のようです。

  1. 静的ファイルの完全一致
  2. 404.html の存在チェック
  3. _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.jsonrun_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.jsfavicon.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は頻繁にアップデートされるため、将来的に改善される可能性があります。