[Cloudflare Workers] Next.jsサイトでCSSが時々読み込まれない問題の原因と対策

発生した症状

Next.js(App Router)+ @opennextjs/cloudflare で Cloudflare Workers にデプロイしたサイトで、CSSが時々読み込まれずスタイルが崩れる現象が発生した。

  • ページにアクセスすると、稀にCSSが適用されていない素のHTMLが表示される
  • ブラウザをリロードするとすぐに直る
  • 特定のページに限らず、どのページでも発生する可能性がある
  • 発生頻度はランダムだが、体感で数回に1回程度

原因

Cloudflare Workers Static Assets のデフォルトキャッシュヘッダー

wrangler.jsonc で以下のように静的アセットを設定している場合、/_next/static/* 配下のCSS・JSファイルは Workerを経由せず、Cloudflareが直接配信する

{
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  }
}

このとき、Cloudflare Workers Static Assets のデフォルトのレスポンスヘッダーは以下のようになる。

Cache-Control: public, max-age=0, must-revalidate

max-age=0 のため、ブラウザはページにアクセスするたびにCSSファイルの再検証リクエスト(条件付きリクエスト)をCloudflareに送る。通常はサーバーが 304 Not Modified を返してキャッシュが利用されるが、エッジノードのタイミングやネットワーク状態によって再検証が失敗すると、CSSが読み込まれない状態になる。

Next.jsのビルド済みアセットは本来キャッシュすべき

Next.jsがビルド時に生成するCSS・JSファイルは、ファイル名にコンテンツハッシュが含まれている。

/_next/static/chunks/8eca778bcaa0b42c.css
/_next/static/chunks/126ee9c4ca88672c.css

ファイルの内容が変わればハッシュも変わり、別のURLになる。つまり同じURLのファイルは内容が変わることがない(immutable) ため、長期間キャッシュしても問題ない。

それにもかかわらず max-age=0 が設定されているため、不要な再検証が毎回発生し、失敗時にCSSが欠落する。

対策1: next.config.mjs に headers() を追加

next.config.mjsheaders() を追加し、/_next/static/* に対して適切なキャッシュヘッダーを設定する。

const nextConfig = {
  // ... 既存の設定
  async headers() {
    return [
      {
        source: '/_next/static/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
    ]
  },
}

ただし、/_next/static/* のファイルは Cloudflare Workers Static Assets によってWorkerを経由せず直接配信される。そのため、Next.jsの headers() 設定はレスポンスに反映されなかった。

対策2: _headers ファイル + ビルドスクリプトでコピー

Cloudflare Workers Static Assets は、アセットディレクトリのルートに _headers ファイルを置くことでレスポンスヘッダーを設定できる(Cloudflare Pages と同じ仕組み)。

public/_headers を作成

/_next/static/*
  Cache-Control: public, max-age=31536000, immutable
  • max-age=315360001年間(= 60 × 60 × 24 × 365秒) のキャッシュ有効期限
  • immutable はブラウザに「このファイルは変更されない」ことを伝え、有効期限内は再検証リクエスト自体を送らなくなる
  • Next.jsのビルド済みアセットはファイル名にコンテンツハッシュが含まれるため、内容が変わればURLも変わる。そのため長期キャッシュしても古いファイルが表示され続ける心配はない

ビルドスクリプトにコピー処理を追加

@opennextjs/cloudflarepublic/_headers.open-next/assets/ に自動コピーしない。そのため package.jsondeploy / preview スクリプトに後処理として追加する。

{
  "scripts": {
    "copy:headers": "cp public/_headers .open-next/assets/_headers",
    "preview": "... && opennextjs-cloudflare build --config wrangler.jsonc && yarn copy:headers && opennextjs-cloudflare preview --config wrangler.jsonc",
    "deploy":  "... && opennextjs-cloudflare build --config wrangler.jsonc && yarn copy:headers && ... && opennextjs-cloudflare deploy --config wrangler.jsonc"
  }
}

opennextjs-cloudflare build 実行後に _headers をコピーすることで、デプロイ・プレビュー時に自動で反映される。

対策3: Cloudflare ダッシュボードの Transform Rules で設定(有料プランのみ)

Cloudflareダッシュボードの Rules > Transform Rules > Modify Response Header から、/_next/static/ 以下のパスに対して Cache-Control: public, max-age=31536000, immutable を返すルールを設定する方法もある。

ただし、この機能は Proプラン以上($20/月〜)が必要なため、無料プランでは使用できない。筆者は試せていないが、Workerや OpenNext の実装に依存せずエッジレベルで確実にヘッダーを書き換えられるため、有料プランを使っている場合は有効な選択肢になると思われる。

レスポンスヘッダーの確認方法

デプロイ後、以下の手順でキャッシュヘッダーが正しく設定されているか確認する。

  1. ブラウザのDevToolsを開く(Cmd + Option + I または F12
  2. Network タブを選択
  3. Disable cache」にチェックを入れてから Cmd + R でリロード(既存キャッシュを除外するため)
  4. フィルタに css と入力して .css ファイルを絞り込む
  5. /_next/static/chunks/〜.css のファイルをクリック → Headers タブ → Response Headers を確認

以下が表示されていれば成功。

cache-control: public, max-age=31536000, immutable
DevToolsのNetworkタブでcache-controlヘッダーを確認している画面

max-age=0 のままであれば _headers が反映されていない。

まとめ

  • Cloudflare Workers Static Assets はデフォルトで Cache-Control: public, max-age=0, must-revalidate を返す
  • next.config.mjsheaders() は Static Assets の直接配信には反映されない
  • public/_headers ファイルで設定できるが、@opennextjs/cloudflare は自動コピーしないため、ビルドスクリプトへの後処理追加が必要
  • Next.jsのビルド済みアセットはコンテンツハッシュ付きで不変なため、max-age=31536000, immutable で長期キャッシュして問題ない
  • 設定後はDevToolsのNetworkタブでレスポンスヘッダーを確認する