「ページが遅い」の解決策は1つではありません。Vercel は4つのキャッシュ層を提供しており、「レスポンス全体か/個別データ片か」「フレームワーク連携か/手動制御か」で使い分けます。層を取り違えると、キャッシュされていないのにされていると思い込む(そして Active CPU 課金とレイテンシで損をする)か、逆に古いデータを出し続ける事故になります。
この記事は ISR と CDN Cache、Runtime Cache の公式仕様に忠実に、4層を実コードで整理します。Next.js の Cache Components / PPR そのものの深掘りは Next.js 16 App Router の Cache Components 記事 に譲り、本稿は 「Vercel プラットフォームとしてどうキャッシュが効くか」 に集中します。全体像は Vercel 本番運用ガイド を参照してください。
4層の地図:まずどれを使うか
| 層 | 対象 | 設定 | 永続性 | 代表的な用途 |
|---|---|---|---|---|
| 静的ファイル | ビルド出力(JS/CSS/画像/フォント) | 自動 | デプロイ寿命(ハッシュ名で跨ぎ持続) | 全デプロイ。考えなくてよい |
| ISR | ページ(HTML+データ) | フレームワークAPI(revalidate等) | 31日・再検証まで | スケジュール更新のページ(EC・CMS・生成系) |
| CDN Cache | 関数のHTTPレスポンス | Cache-Control 系ヘッダ | 最大1年(ベストエフォート) | ISR非対応・APIレスポンスを地域別にキャッシュ |
| Runtime Cache | 関数内のデータ片 | Runtime Cache API | タグ無効化まで | fetch結果・DBクエリ・計算値の再利用 |
判断のショートカット:
- ページ全体を出すフレームワーク(Next.js等)→ ISR(最強の機能群が自動で付く)
- API のレスポンスを地域キャッシュ → CDN Cache(Cache-Control)
- 関数の中で同じデータを何度も取る → Runtime Cache
- 静的アセット → 何もしない(自動)
ISR:最も多機能なキャッシュ
stale-while-revalidate の挙動
ISR は「キャッシュ済みを即返し、裏で再生成」。Vercel の CDN に乗ることで、フレームワークの ISR は次を自動で得ます(ISR docs)。
- 31日間の永続ストレージ:Function リージョンに永続。デプロイごとに独立したキャッシュ。
- 300ms のグローバル purge:再検証時、全リージョンが 300ms 以内に更新。HTML とデータをアトミックに差し替える(全ページ遷移とクライアント遷移で内容が食い違わない)。
- リクエスト併合:未キャッシュの同一パスに同時アクセスが殺到しても、リージョンごとに関数を1回だけ呼ぶ。スパイク時のオリジン保護。
- 即時ロールバック耐性:過去デプロイのキャッシュは消えないので、ロールバックしても生成済みコンテンツを失わない。
- キャッシュシールド:CDN ミス時、関数を呼ぶ前に永続 ISR キャッシュを読む。
// app/blog/[slug]/page.tsx
// 時間ベース再検証:3600秒ごとに裏で再生成
export const revalidate = 3600;
// 人気ページはビルド時に事前生成、それ以外はオンデマンド生成
export async function generateStaticParams() {
const popular = await getPopularSlugs();
return popular.map((slug) => ({ slug }));
}
export default async function Post({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <Article post={await getPost(slug)} />;
}
オンデマンド再検証:イベントで即時反映
「3600秒待たず、CMS で公開した瞬間に反映したい」——これがオンデマンド再検証です。revalidatePath/revalidateTag を使います。
// app/api/revalidate/route.ts — CMS の Webhook から叩く
import { revalidatePath, revalidateTag } from "next/cache";
export async function POST(request: Request) {
// ① 認可(無防備な再検証エンドポイントは攻撃面になる)
if (request.headers.get("authorization") !== `Bearer ${process.env.REVALIDATE_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const { slug, tag } = await request.json();
if (slug) revalidatePath(`/blog/${slug}`); // パス単位
if (tag) revalidateTag(tag); // タグ単位(複数ページ横断)
return Response.json({ revalidated: true });
}
データ取得側でタグを付けておくと、revalidateTag("posts") のように横断的に無効化できます。
// fetch にタグを付ける(Next.js)
const posts = await fetch("https://cms.example.com/posts", {
next: { tags: ["posts"], revalidate: 3600 },
}).then((r) => r.json());
再検証が失敗したら? Vercel はステイルを返し続け、30秒の TTL で再試行します。再検証成功とみなすステータスは
200/301/302/307/308/404/410のみ。これ以外・ネットワークエラー・関数エラーは失敗扱いです。だから「再検証先が一時的に落ちても、ユーザーには古いページが出続ける」——可用性に効く設計です。
対応フレームワーク
ISR は Next.js 専用ではありません。
| フレームワーク | 有効化 |
|---|---|
| Next.js(App Router) | ルートセグメントから revalidate を export |
| Next.js(Pages Router) | getStaticProps が revalidate を返す |
| SvelteKit | config に isr プロパティ |
| Nuxt | routeRules に isr |
| Astro | server 出力で ISR 設定 |
| Gatsby | DSG(Deferred Static Generation) |
Cache Components(PPR):静的シェルと動的穴の両立
Next.js 16 の Cache Components(Partial Prerendering, PPR) は、「1ページの中で、静的な殻(シェル)を即配信しつつ、動的な部分だけ穴を空けてストリームする」モデルです。use cache ディレクティブで境界を宣言し、cacheLife/cacheTag で寿命とタグを制御します。
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function Dashboard() {
return (
<>
<StaticHeader /> {/* 静的シェル:即配信 */}
<Suspense fallback={<Skel />}>
<UserStats /> {/* 動的な穴:ストリーム */}
</Suspense>
</>
);
}
// キャッシュ境界を宣言(コンポーネント/関数単位)
async function getCatalog() {
"use cache";
cacheLife("hours"); // 寿命プロファイル
cacheTag("catalog"); // タグ付け → updateTag/revalidateTag で無効化
return db.products.findMany();
}
Vercel 上では、この静的シェルが ISR/CDN に乗り、動的穴は Fluid Compute の関数がストリーミングで埋めます。タグ単位の無効化(updateTag/revalidateTag)で、書き込み後に該当タグだけ即時更新できます。
Cache Components の API(
use cache・cacheLife・cacheTag・updateTag・unstable_cacheからの移行)の詳細は、フロントエンドクラスタの Next.js 16 Cache Components 記事 に集約しています。本稿では「Vercel 上でどう配信されるか」だけ押さえれば十分です。
CDN Cache:関数レスポンスを3ヘッダで制御
ISR を使わない API・SSR でも、Cache-Control を返せばそのリージョンの CDN にキャッシュされます。Vercel はブラウザ/下流CDN/Vercel CDN を別々に制御できる3ヘッダを提供します(CDN Cache docs)。
// app/api/catalog/route.ts
export async function GET() {
return Response.json(await getCatalog(), {
headers: {
"Cache-Control": "public, max-age=10", // ブラウザ:10秒
"CDN-Cache-Control": "public, s-maxage=60", // 下流CDN:60秒
"Vercel-CDN-Cache-Control": "public, s-maxage=3600", // Vercel:3600秒
},
});
}
| ヘッダ | 制御先 | ブラウザに返るか |
|---|---|---|
Cache-Control | ブラウザ+(CDN指定がなければ)CDN | ✅ |
CDN-Cache-Control | 下流CDN・Vercel CDN(ブラウザは無視) | ✅ |
Vercel-CDN-Cache-Control | Vercel CDN のみ | ❌(返らない・転送されない) |
関数レスポンスを CDN にキャッシュするには s-maxage=N(任意で , stale-while-revalidate=Z)が必要です。CDN-Cache-Control を指定せず Cache-Control だけ書くと、Vercel はブラウザ送信前に s-maxage/stale-while-revalidate を剥がします(ブラウザに内部キャッシュ方針を漏らさないため)。
キャッシュ可能条件(厳格)
レスポンスが CDN にキャッシュされるには、すべてを満たす必要があります。
- リクエストが
GETまたはHEAD、Range/Authorizationヘッダを含まない - レスポンスが
200/404/410/301/302/307/308 - コンテンツ長 10MB 以下(ストリーミングは 20MB)
Set-Cookieを含まないCache-Controlにprivate/no-cache/no-storeを含まないVary: *を含まない
AuthorizationとSet-Cookieが鬼門:認証付き API がキャッシュされない最頻原因です。ログイン必須ページは基本キャッシュしない(またはVaryとユーザー非依存部分の分離で設計)。
Vary でユーザー属性ごとに分ける
国・言語・デバイスで内容を変えつつキャッシュしたいときは Vary。Vercel は X-Vercel-IP-Country 等のリクエストヘッダをキャッシュキーに加えられます。
export async function GET(request: Request) {
const country = request.headers.get("x-vercel-ip-country") ?? "unknown";
return Response.json({ greeting: `Hello from ${country}` }, {
headers: { "Cache-Control": "s-maxage=3600", Vary: "X-Vercel-IP-Country" },
});
}
Vary は1つ増えるごとにキャッシュエントリが指数的に増える(=ヒット率が下がる)ので、本当に内容を変えるヘッダだけに絞ります。
Runtime Cache:関数内のデータ片をキャッシュ
レスポンス全体ではなく、関数の中で「同じ fetch・同じ DB クエリ・同じ計算結果」を再利用したいとき。Runtime Cache はリージョンごとのエフェメラルな KV で、タグで無効化でき、Functions・Routing Middleware・Builds 間で共有されます。
// 高コストな計算を Runtime Cache に載せる(疑似コード)
import { getCache } from "@vercel/functions";
export async function GET() {
const cache = getCache();
const key = "exchange-rates";
const cached = await cache.get(key);
if (cached) return Response.json(cached); // データ片の再利用
const rates = await fetchExchangeRatesFromUpstream(); // 高コスト
await cache.set(key, rates, { tags: ["rates"], ttl: 300 });
return Response.json(rates);
}
ISR や Cache-Control が「レスポンス全体」を扱うのに対し、Runtime Cache は「個別のデータ片」を扱うのが本質的な違いです。両者は併用できます(ページは ISR、ページ内の一部 fetch は Runtime Cache)。
デバッグ:x-vercel-cache を読む
「キャッシュされているつもりで、実は毎回関数が走っている」を防ぐ唯一の方法は、x-vercel-cache ヘッダを実際に見ることです。
curl -sI https://your-app.vercel.app/api/catalog | grep -i x-vercel-cache
# x-vercel-cache: HIT
| 値 | 意味 |
|---|---|
HIT | CDN キャッシュから配信(関数は走っていない) |
MISS | キャッシュになく、オリジン(関数)で生成 |
STALE | 期限切れだがステイルを配信、裏で再検証中 |
PRERENDER | ビルド時に事前生成された静的コンテンツ |
本番リリース前に、キャッシュさせたいパスが HIT/PRERENDER になっているかを必ず確認してください。これが Active CPU 課金とレイテンシを直接左右します(コスト最適化ガイド)。
本番チェックリスト(キャッシュ)
- キャッシュ層を4つから意図的に選択している(なんとなく SSR ではない)
- ISR の
revalidateを要件に合わせて設定、人気ページはgenerateStaticParams - オンデマンド再検証エンドポイントを認可している
- 関数キャッシュは
s-maxageを付け、3ヘッダの役割を理解 - 認証付きレスポンスを誤ってキャッシュしていない(Authorization/Set-Cookie)
-
Varyは本当に内容を変えるヘッダだけ -
x-vercel-cacheでHIT/PRERENDERを実測確認 - CWV(LCP/INP/CLS)を Speed Insights で監視(CWV 最適化)
まとめ
キャッシュは「速さ」だけでなく、コスト(Active CPU を呼ばない)と可用性(再検証失敗時もステイル配信) を同時に作ります。
- 層を選ぶ:ページは ISR、API は CDN Cache、データ片は Runtime Cache、静的は自動
- ISR の自動最適化(31日永続・300ms purge・リクエスト併合・ロールバック耐性)を活かす
- オンデマンド再検証で即時反映、Cache Components でシェルと穴を両立
- 3ヘッダで階層制御し、キャッシュ可能条件を満たす
x-vercel-cacheで必ず実測する
次は、作ったものを安全に出す デプロイ・CI/CD・ロールバック ガイド へ。
本記事は ISR / CDN Cache / Runtime Cache 公式ドキュメント(2026年6月時点)に基づきます。仕様は更新されるため、本番採用時は公式で最新値を確認してください。