# CloudFront で Next.js の RSC がキャッシュ事故を起こす話

> 「新規ページが人によって見れない」の原因は CloudFront のキャッシュキーだった。Next.js App Router の RSC が絡むキャッシュ汚染を、Free プランのままマネージド Cache Policy だけで直した記録です。

- 公開日: 2026-07-21
- 著者: 古川
- タグ: Next.js, CloudFront, キャッシュ
- URL: https://tech.anycloud.co.jp/articles/cloudfront-nextjs-rsc-cache

---

## 「新規ページが人によって見れない」

社内でこんな報告が上がってきました。

> 新しく作ったページがなぜか見れない

最初は 404 かと思ったのですが、よく聞くと 404 ではなく「表示が壊れて見れない」状態でした。しかも厄介なのが、**自分の環境では普通に見れてしまう**こと。ブラウザを変えても再現しません。見れる人と見れない人がいる、というやつです。

見れないと言われたのは Next.js（App Router）で新規追加したページでした。「人によって・タイミングによって挙動が変わる」というのは、だいたいキャッシュが絡んでいます。今回もそうで、犯人は前段に立てている CloudFront のキャッシュキー設定でした。

この記事は、その原因調査と、**CloudFront の Free プラン（カスタム Cache Policy が使えない）のまま直した**記録です。ネットで見つかる対策の多くはカスタム Cache Policy 前提で、Free プランだとそのままでは真似できなかったので、そこを中心に書きます。

## 原因：Next.js は同じ URL で 2 種類のレスポンスを返す

まず前提から。Next.js の App Router は、**同じ URL でもリクエストの種類によって返す中身が 2 種類**あります。

- **フルロード**（アドレスバー直打ち・リロード・外部サイトから流入）→ 完成品の **HTML**
- **サイト内のリンク遷移**（`<Link>` クリックやプリフェッチ）→ **RSC ペイロード**（React Server Components の差分データ。`text/x-component` という HTML とは別形式）

サーバはリクエストに付く `RSC: 1` ヘッダを見て、HTML か RSC ペイロードかを出し分けています。加えてクライアント遷移のときは URL に `?_rsc=<hash>` というクエリも付きます。

普通の静的サイトなら「1 つの URL = 1 つの中身」なので、CloudFront がパス単位でキャッシュしても問題ありません。ところが Next.js は「1 つの URL = 2 つの中身」。ここでキャッシュキーの設計を誤ると事故ります。

CloudFront のキャッシュキー（＝どのレスポンスを再利用するかを決めるラベル）が **パスだけ**で作られていると、この 2 種類を区別できません。棚が 1 つしかないイメージです。

- 誰かがリンク経由で先にアクセス → 棚に「RSC ペイロード」が入る
- 次に別の人が URL 直打ちで来る → 棚の「RSC ペイロード」が返る
- ブラウザは HTML を期待しているので表示できない = 「見れない」

どちらが先に棚へ入るかはアクセス順とエッジ拠点次第です。**新規ページはキャッシュが空っぽなので「最初の 1 アクセスが何を入れるか」で決まりやすく**、だから新規ページで発生し、人によって見れたり見れなかったりします。

## curl で現状を確認する

推測だけだと確信が持てないので、`curl` で同じ URL を 2 通りの叩き方をして比べました。ヘッダとレスポンス本文の先頭を見ます。

```bash
URL="https://<distribution>.cloudfront.net/<新規ページのパス>"

# 1) フルロード相当（RSC ヘッダなし）→ HTML が返るべき
curl -sS -D - -o /tmp/plain.body -H 'Accept: text/html' "$URL"

# 2) クライアント遷移相当（RSC ヘッダあり）→ RSC ペイロードが返るべき
curl -sS -D - -o /tmp/rsc.body \
  -H 'Accept: text/x-component' -H 'RSC: 1' -H 'Next-Router-Prefetch: 1' "$URL"
```

結果がこれです。

| | 1) フルロード | 2) RSC ヘッダあり |
| --- | --- | --- |
| content-type | `text/html` | `text/html`（RSC ペイロードでない） |
| body | 67,111 bytes | 67,111 bytes（バイト単位で完全一致） |
| x-cache | `Hit from cloudfront` | `Hit from cloudfront` |
| age | 4191 | 4191（同一 = 同じキャッシュ実体） |
| vary | `Accept-Encoding` | `Accept-Encoding` |

`RSC: 1` を付けて「差分データちょうだい」と要求しているのに、返ってきたのは HTML。しかも 1) と 2) が**バイト単位で完全一致**、`age` も同じ 4191 秒でした。つまり CloudFront が **RSC ヘッダを見ずに 1 つの棚を使い回している**動かぬ証拠です。

決め手は `vary: Accept-Encoding` だけだったこと。`RSC` などのヘッダが vary に入っていない、つまりキャッシュキーが RSC を区別していません。

ついでにクエリ文字列も確認しました。適当なキャッシュバスター `?cb=xxxx` を付けても `x-cache: Hit`（`age` も変わらず）だったので、**クエリ文字列もキャッシュキーに入っていない**ことが分かりました。キャッシュキーが実質パスだけ、という状態です。

## よく出てくる対策はカスタム Cache Policy 前提だった

この現象、検索すると対策はだいたい「**CloudFront のキャッシュキーに `_rsc` を含める**」に集約されます。具体的には**カスタム Cache Policy を作り、キャッシュキーに含めるクエリ文字列を `_rsc` に、必要なら `RSC` 系ヘッダも追加する**というものです。

参考にした記事や Issue もこの方針でした。

- [Next.jsでRSC Payloadがブラウザに表示される問題と対処法 - Qiita](https://qiita.com/tsukasaI/items/05a4b6458c0d0e3f8402)
- [CloudFront CachePolicyにNext.js RSCヘッダーが含まれておらずキャッシュ汚染が発生する · Issue #100 · aws-samples/serverless-full-stack-webapp-starter-kit](https://github.com/aws-samples/serverless-full-stack-webapp-starter-kit/issues/100)

いざ AWS コンソールでカスタム Cache Policy を作ろうとして、詰まりました。

## ハマりどころ：Free プランだとカスタム Cache Policy が作れない

CloudFront の Cache Policy 選択画面で、カスタム欄が **「Available with the Business plan」** とグレーアウトされていて選べません。このディストリビューションは Free プランで、カスタム Cache Policy は上位プランの機能でした。

つまり、ネットで見つかる「カスタムで `_rsc` をキーに入れる」がそのままでは使えない。ここが今回いちばん引っかかった点です。

選べるのはマネージドのプリセットが 2 つだけでした。

- `UseOriginCacheControlHeaders` — オリジンの Cache-Control を尊重。**クエリ文字列はキャッシュキーに含めない**
- `UseOriginCacheControlHeaders-QueryStrings` — 同上だが、**クエリ文字列の値に応じて別の中身をキャッシュする**

現状チェックが付いていたのは前者で、これはさっき確認した「クエリを無視する」挙動と一致します。原因そのものです。

## 解決：マネージドの -QueryStrings で十分だった

ここで思い出したのが、**RSC のクライアント遷移には必ず `?_rsc=…` が付き、フルロードには付かない**という点です。

- フルロード：`/path`（クエリなし）
- RSC 遷移：`/path?_rsc=abc…`（クエリあり）

つまりクエリ文字列を見るだけで 2 種類は区別できます。ヘッダをキーに足さなくても、**クエリをキーに足せば棚が分かれる**。それをやってくれるのが後者の `UseOriginCacheControlHeaders-QueryStrings` です。

Cache Policy を `UseOriginCacheControlHeaders-QueryStrings` に切り替えて保存し、汚染済みの在庫を捨てるために `/*` の Invalidation を実行しました。設定を直しても古い実体が残っていると返り続けるので、ここは必須です（特に今回は `cache-control: s-maxage=31536000`、1 年でエッジに居座る設定でした）。

Invalidation 完了後、同じ curl をもう一度回しました。

| | 1) フルロード | 2) RSC ヘッダ + `?_rsc` |
| --- | --- | --- |
| content-type | `text/html` | `text/x-component` |
| body 先頭 | `<!DOCTYPE html>` | `1:"$Sreact.fragment"`（RSC ペイロード） |
| サイズ | 67,111 bytes | 33,704 bytes |

2 種類が別々の棚に分かれ、RSC リクエストにはちゃんと `text/x-component` の差分データが返るようになりました。2 回目以降は同じキーで `x-cache: Hit` になり、キャッシュもきちんと効いています。

もう一つ確認できたのは、RSC リクエストが正しく差分データを返せたということは、**オリジン（Next.js）に `RSC` ヘッダが届いていた**ということです。ここが届いていないと、クエリで棚は分かれても中身がまた HTML になってしまう。事前には設定画面だけで判断できなかったので、切り替え → Invalidation → 再確認、の順で白黒つけました。結果、`-QueryStrings` だけで完全に直り、Business プランへのアップグレードもカスタム Cache Policy も不要でした。

## トレードオフ：全クエリがキーに乗る

`-QueryStrings` は `_rsc` だけでなく**すべてのクエリ文字列**をキャッシュキーに含めます。そのため `?utm_source=...` のような広告・計測用のクエリが付いた URL は、中身が同じ HTML でも値ごとに別の棚になり、キャッシュのヒット率が少し下がります。

とはいえ表示は正しいままで実害は小さいので、今回はこのままにしました。もしヒット率を最適化したくなったら、その時はカスタム Cache Policy を使い、キーに含めるクエリを `_rsc` だけに絞るのが本筋です。それには少しプラン側の話が関わるので、次で触れます。

## なぜ参考記事はカスタムできて、今回はできなかったのか

そもそも、参考記事は当たり前のようにカスタム Cache Policy を使っているのに、なぜ今回のディストリビューションは使えなかったのか。調べてみると、この差は記事の新旧でも払っている金額の大小でもなく、**ディストリビューションが「フラットレートのプラン制」に乗っているか「従量課金（pay-as-you-go）」かの違い**でした。

CloudFront には今、月額固定でまとめて課金するプラン制（Free / Pro / Business / Premium）と、サービスごとに使った分だけ払う従量課金の 2 系統があります。[公式のプラン別機能表](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/flat-rate-pricing-plan.html)を見ると、カスタム Cache Policy（custom caching rules）は Business 以上でしか使えず、Free と Pro には含まれていません。今回の staging はプラン制の Free に乗っていたので、ここで弾かれていました。

一方、従量課金のディストリビューションはカスタム Cache Policy も Origin Request Policy も追加費用なしで自由に使えます。参考記事が CDK や CloudFormation で組んでいるのはこの従量課金側なので、そもそもプランの壁が存在しません。「向こうはできてこちらはできない」のは、置かれている土俵が違っただけでした。

どうしてもカスタムを使いたい場合、有料の Business プラン（執筆時点で月 200 ドル前後）へ上げるほかに、そのディストリビューションを従量課金へ切り替える手もあります。従量課金ならカスタム Policy は追加費用なしで使え、CloudFront の永続無料枠（月 1TB 転送・1000 万リクエスト）があるので低トラフィックの staging ならほぼ無料で収まります。ただしプランがまとめて見てくれていた WAF や DNS などは個別課金に戻るので、そこは天秤にかける必要があります。

## まとめ

「新規ページが人によって見れない」の正体は、Next.js の HTML と RSC ペイロードを CloudFront が区別できずに片方だけキャッシュしていたこと、でした。

Free プランでカスタム Cache Policy が使えなくても、RSC 遷移が必ず `?_rsc` を伴う性質を利用して、マネージドの `UseOriginCacheControlHeaders-QueryStrings` に切り替えるだけで直せます。設定を変えたら `/*` の Invalidation を忘れずに、そして「オリジンに RSC ヘッダが届いているか」を curl で確認するところまでやると安心です。
