Next.js で多段キャッシュを設計する — CloudFront / Redis / TanStack Query を全部つなぐ
対象: Next.js 16 (App Router) + React 19 でフロントを構築し、API バックエンドと組み合わせるプロジェクト
想定読者: 「キャッシュ層がいくつもあって、どこで何が効いているのか整理したい」人
忙しい人向けの結論
実運用では以下のレイヤーが重なります。
| # | レイヤー | 場所 | 効く範囲 |
|---|---|---|---|
| ① | CloudFront (CDN) | エッジ | 静的アセット / 静的JSON / next/image 経由の最適化済み画像 |
| ② | Next.js Data Cache (use cache) |
サーバー(既定はプロセス内 in-memory LRU) |
use cache を付けた Server Component / 関数の結果 |
| ③ | Next.js Image Cache | サーバー(.next/cache/images) |
<Image> で最適化した結果(WebP/AVIF) |
| ④ | Redis (Custom Cache Handler) | Next.js の外側(共有ストア) | 複数インスタンス間での use cache 共有 / invalidation 共有が必要なときの選択肢 |
| ⑤ | TanStack Query | クライアント | ブラウザ内のサーバ状態 |
| ⑥ | Browser HTTP Cache / Router Cache | クライアント |
Cache-Control に従う静的リソース / 訪問済みルートの RSC ペイロード |
この記事では番号 ①〜⑥ を全体で統一して使います(後述の本文見出し「レイヤー1〜6」と 1 対 1 で対応)。
「全部入れる」のではなく、データの鮮度要件・パーソナライズ有無・更新頻度 に応じて適切な層に乗せます。
📌 前提(Next.js 16): 本記事の Data Cache(
use cache)は Cache Components 機能です。next.config.tsにcacheComponents: trueを設定しないとuse cache/cacheLife/cacheTagは一切機能しません(詳細はレイヤー2)。
前提:何をキャッシュして、何をキャッシュしないか
✅ キャッシュして良いもの
-
公開コンテンツ(誰が見ても同じデータ)
- SEO メタデータ(タイトル・OGP)
- 公開記事・商品情報、お知らせ一覧
- 静的画像・フォント・CSS/JS バンドル
-
更新頻度が低いマスタデータ
- 利用規約
-
再計算コストが高い集計値
- ダッシュボードの集計(鮮度を許容できる範囲で)
❌ キャッシュしてはいけないもの
-
個人情報・PII
- ログインユーザーのプロフィール、購入履歴、メールアドレス
- CDN・サーバー共有キャッシュ・Redis など、他ユーザーに混入しうる全てのキャッシュ層に乗せない
- クライアント側(TanStack Query 等のメモリ)で当該ユーザーセッション内に閉じて持つのは可
-
権限依存のデータ
- 管理者のみ閲覧可能なリスト、限定公開コンテンツ
-
トークン・セッション・パスコード
- 認証情報そのもの
-
下書き / プレビュー
- 公開前データ。共有キャッシュをバイパスして API 直
-
リアルタイム性が必須
- 在庫数、抽選結果、決済結果
⚠️ 注意が必要なもの
-
「ログイン状態に応じて表示が変わるが、データ自体は公開」
- 例: 誰が見ても同じ公開記事・商品ページのヘッダーに、「ログイン中のユーザー名」やカート個数だけを差し込む
- → 本文(公開データ)は CDN キャッシュ可。ユーザー固有の差し込みはクライアント側で(サーバー側でユーザー単位にキャッシュしたい場合は
use cache: privateという選択肢もある)
全体像
リクエストの流れと各キャッシュレイヤーの位置関係:
番号と対応レイヤー
| # | レイヤー | 場所 |
|---|---|---|
| ① | CloudFront Edge Cache | CDN(エッジ) |
| ② | Next.js Data Cache (use cache) |
Next.js サーバー(既定は in-memory LRU) |
| ③ | Next.js Image Cache (.next/cache/images) |
Next.js サーバー |
| ④ | Redis (Custom Cache Handler) | Next.js 外部の共有ストア |
| ⑤ | TanStack Query | クライアント(メモリ) |
| ⑥ | Browser HTTP Cache / Router Cache | クライアント(ブラウザ / Next.js 内部) |
ポイントは Redis が API サーバー側ではなく、Next.js の use cache の "共有バックエンド" として配置される こと。これにより複数 ECS タスクで同じキャッシュを参照できます。ただし片方のタスクで revalidateTag() を呼んだ結果が他タスクに伝わるのは 自動ではなく、Cache Handler が共有ストア経由でタグ状態を同期する実装(分散タグ協調)を持つ場合に限ります(詳細はレイヤー4)。
レイヤー1: CloudFront (CDN) [1]
持ち方
CloudFront ビヘイビアでパスごとにオリジンを切り替えます。
| パス | オリジン | TTL の考え方 |
|---|---|---|
/_next/static/* |
Next.js | immutable(ファイル名にハッシュ) |
/_next/image* |
Next.js | minimumCacheTTL に従う |
/api/* |
Next.js | キャッシュしない(or 短TTL) |
| その他 | Next.js | SSR 結果。基本キャッシュしない |
キャッシュ無効化
- リリース時に CloudFront Invalidation を発行(
/_next/image*など) - ただし invalidation は反映までラグがあるため、ファイル名へのハッシュ付与で実質 immutable にする方が望ましい
確認方法
ブラウザ DevTools の Network タブで該当リクエストのレスポンスヘッダーを確認:
x-cache: Hit from cloudfront
x-amz-cf-pop: NRT57-P1
age: 142
-
x-cache: Hit from cloudfront→ エッジヒット -
x-cache: Miss from cloudfront→ オリジンに到達 -
ageでキャッシュされてからの経過秒数
レイヤー2: Next.js Data Cache (use cache) [3]
Next.js 16 では "use cache" ディレクティブでサーバー側の結果をキャッシュできます。エントリは 既定でサーバープロセス内の in-memory LRU に保存されます。保存先を共有ストアに変えたい場合はレイヤー4の cacheHandlers / use cache: remote を使います。
⚠️ 前提:
cacheComponents: trueが必須 [2]
use cache/cacheLife/cacheTagは Next.js 16 の Cache Components 機能です。next.config.tsで有効化しないと、これらのディレクティブ・関数は一切機能しません。// next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, // ← これが無いと use cache は効かない }; export default nextConfig;
cacheComponentsは旧experimental.dynamicIO/experimental.useCache/experimental.pprを統合したフラグです。有効化すると Partial Prerendering (PPR) がデフォルト化し、データ取得がデフォルトで動的(dynamic by default) になります。「何をキャッシュするか」をuse cacheで明示的に選ぶモデルに変わるため、既存プロジェクトへ導入する際は影響範囲を確認してください。
"use cache" の置き場所は2通り
use cache を書ける場所は 「インライン(関数・コンポーネントの先頭)」 と 「ファイル先頭」 の2通りです。インラインの場合、その関数が データを返せば関数キャッシュ、JSX を返せばコンポーネント(レンダリング結果)キャッシュになります(=同じインライン配置の2バリエーション)。
| 配置 | 書く場所 | キャッシュ対象 | 使いどころ |
|---|---|---|---|
| インライン(関数) | async 関数の先頭 | 関数の戻り値(データ) | 単一のデータ取得を再利用したい |
| インライン(コンポーネント) | Server Component の先頭 | JSX のレンダリング結果 + 内部のデータ | データ取得 + レンダリングコストごとキャッシュしたい |
| ファイル先頭 | ファイルの先頭 | そのファイルから export される全関数 | データ取得モジュールをまとめてキャッシュ |
以下、3つのパターンを順に見ていきます(①②がインライン、③がファイル先頭)。
① 関数単位(インライン) — 戻り値だけキャッシュ
import { cacheLife, cacheTag } from "next/cache";
export async function getProducts(category: string) {
"use cache";
cacheLife("shortTerm");
cacheTag("products", `products-${category}`);
const res = await fetch(`https://api.example.com/products?category=${category}`);
return res.json();
}
- 同じ引数
categoryで呼ばれたら キャッシュ済みの戻り値 を返す - 呼び出し側はサーバー / クライアント / 別関数のどこからでも OK
- 「データ取得処理だけ重い」ケースに最適
② コンポーネント単位(インライン) — レンダリング結果ごとキャッシュ
// app/components/ProductList.tsx
import { cacheLife, cacheTag } from "next/cache";
type Product = { id: string; name: string; price: number };
export default async function ProductList({ category }: { category: string }) {
"use cache";
cacheLife("shortTerm");
cacheTag("products", `products-${category}`);
const products: Product[] = await fetch(
`https://api.example.com/products?category=${category}`,
).then((r) => r.json());
return (
<ul className="product-list">
{products.map((p) => (
<li key={p.id}>
<span>{p.name}</span> — <span>¥{p.price.toLocaleString()}</span>
</li>
))}
</ul>
);
}
- データ取得 + JSX のレンダリング結果(RSC ペイロード) をまとめてキャッシュ
- 親ページから
<ProductList category="shoes" />のように呼ぶだけで、同じcategoryの組み合わせは即座に返る - レンダリングコスト(map / 計算 / フォーマット)が重い場合に効く
⚠️ コンポーネント単位のキャッシュはレンダリング結果を共有するので、ユーザーごとに異なる UI(ログイン名表示など)を含めてはいけない。引数(props)が同じなら誰が見ても同じ表示になる場合のみ使う。
また
use cacheを付けた関数・コンポーネントの 引数と戻り値は直列化可能(serializable)である必要があります。クラスインスタンスや関数は渡せません(childrenなどは中身を参照しない pass-through なら可)。非直列化の props を渡すとビルドエラーになります。
③ ファイル先頭 — モジュールごとまとめてキャッシュ
// app/lib/posts.ts
"use cache";
import { cacheLife, cacheTag } from "next/cache";
export async function getPosts() {
cacheLife("shortTerm");
cacheTag("posts");
return fetch("https://api.example.com/posts").then((r) => r.json());
}
export async function getPostById(id: string) {
cacheLife("shortTerm");
cacheTag("posts", `posts-${id}`);
return fetch(`https://api.example.com/posts/${id}`).then((r) => r.json());
}
- このファイル内の 全 export 関数 が自動的にキャッシュ対象
- データ取得ユーティリティをひとつのファイルにまとめて管理しているケース向け
cacheLife プロファイル例
next.config.ts の トップレベルで定義します(cacheComponents: true とセット。Next.js 15 時代の experimental.cacheLife ではありません):
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
realtime: { stale: 0, revalidate: 1, expire: 60 },
shortTerm: { stale: 60, revalidate: 300, expire: 3600 },
longTerm: { stale: 3600, revalidate: 86400, expire: 604800 },
},
};
export default nextConfig;
各プロファイルは3つの値を持ちます。
-
stale: クライアント側でキャッシュをフレッシュとみなす秒数(この間は再検証しない) -
revalidate: サーバー側でバックグラウンド再検証を行う間隔の秒数 -
expire: キャッシュが完全に失効するまでの秒数(これを過ぎると次のリクエストはフレッシュ取得を待つ)
⚠️
expireはrevalidateより長い値にする必要があります。
タグでピンポイント破棄(updateTag / revalidateTag) [4]
Next.js 16 では更新系のキャッシュ破棄 API が 2 つに整理されました。
| API | 用途 | 挙動 |
|---|---|---|
updateTag(tag) |
Server Action 内で「更新直後に最新値を見せたい」(read-your-writes) | 即時失効。次リクエストはフレッシュ取得を待つ |
revalidateTag(tag, profile) |
Route Handler / Webhook 等。多少の遅延は許容 | stale-while-revalidate(次回アクセス時に背景で再取得) |
⚠️ Next.js 16 では 単一引数の
revalidateTag(tag)は非推奨(TS エラーになり将来削除の可能性)。第2引数に cacheLife プロファイル(例"max")または{ expire: 0 }を渡します。
"use server";
import { updateTag } from "next/cache";
type ProductInput = { name: string; price: number };
export async function updateProduct(category: string, payload: ProductInput) {
await fetch(`https://api.example.com/products/${category}`, {
method: "PUT",
body: JSON.stringify(payload),
});
// Server Action: 更新直後に最新を見せる(read-your-writes)
updateTag(`products-${category}`);
}
Route Handler / Webhook など、即時反映までは不要で背景再検証で良い場合:
import { revalidateTag } from "next/cache";
// stale-while-revalidate(次にこのタグのページが訪問されたとき更新)
revalidateTag(`products-${category}`, "max");
// 外部サービスから即時失効させたいなら
// revalidateTag(`products-${category}`, { expire: 0 });
Redis Custom Cache Handler を入れていれば、このタグ無効化を 他の ECS タスクにも反映できますが、それは Cache Handler が共有ストア経由でタグ状態を同期する実装を持つ場合に限ります(レイヤー4参照)。in-memory のままなら このタスク内のキャッシュだけ が破棄されます。
確認方法
ログで確認(use cache はこれが主軸)
use cache のエントリは既定で プロセス内 in-memory に乗るため、ファイルを覗いても見えません。ヒット/ミスは NEXT_PRIVATE_DEBUG_CACHE=1 のログで確認します:
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev
保存先を Redis 等に変えた場合(レイヤー4の cacheHandlers / use cache: remote)は、そのストア側(例: redis-cli KEYS ...)で確認します。
.next/cache/ の中身(注意)
.next/cache/ 配下には主に次が書き出されます:
.next/cache/
├── fetch-cache/ # 旧 fetch ベースの Data Cache(fetch(url, { next: { revalidate } }))
├── images/ # 画像最適化キャッシュ
└── webpack/ # ビルドキャッシュ
※
fetch-cache/は従来のfetch()/ ISR キャッシュ用ディレクトリです。use cacheのエントリはここには現れません(既定で in-memory のため)。use cacheを題材にこのディレクトリをlsしても期待どおりに見えない点に注意してください。
レイヤー3: Next.js Image Cache(next/image) [5]
<Image> を使うと Next.js サーバー側で画像を最適化(WebP/AVIF 変換、リサイズ)し、結果を .next/cache/images/ に保存します。
🎯 方針の前提: 画像は CloudFront を主役にする
画像配信は CloudFront(外側)を耐久キャッシュの主役にするのが第一選択です。CloudFront と Next.js Image Cache は「どちらか」ではなく役割が違います。
守るもの 効く場面 CloudFront エッジレイテンシ+オリジン到達そのもの ほぼ全リクエスト(HIT なら ECS に来ない) Next.js Image Cache( .next/cache/images/)オリジンの最適化CPU(sharp の再エンコード) CloudFront が MISS したときだけ ECS / コンテナ運用では
.next/cache/images/は タスクごとに独立し、デプロイで揮発します。つまりこれを耐久層として当てにはできません。next/imageの最適化を使う限りこのキャッシュは原則無効化できませんが、**「CloudFront が耐久層、.next/cache/images/は CloudFront MISS 時にオリジンの再最適化を減らすベストエフォート」**と割り切るのは妥当な方針です。
持ち方
import Image from "next/image";
<Image src="/images/hero.png" width={1200} height={630} alt="..." />
- リクエストごとに
width/qualityの組み合わせで最適化バリエーションが生成される - 結果は
.next/cache/images/{hash}/{etag}.{ext}に保存 - レスポンスは
Cache-Control: public, max-age=...付きで返るので CloudFront でもキャッシュ可能
TTL 設定
next.config.ts:
images: {
minimumCacheTTL: 60 * 60 * 24 * 7, // 7日間
formats: ["image/avif", "image/webp"],
qualities: [75], // Next.js 16 では既定以外の quality を使うなら明示が必須
remotePatterns: [
{ protocol: "https", hostname: "cdn.example.com" },
],
}
-
minimumCacheTTLで.next/cache/images/側の保持期間を制御(Next.js 16 で既定が60s→14400s(4時間) に変更) -
formatsの既定は["image/webp"]のみ。AVIF はオプトイン(上記のように明示) -
Next.js 16 では
qualityprop を既定(75)以外で使う場合images.qualitiesの指定が必須。未指定だと 400 になりうる - 外部画像は 必要最小限のドメインのみ
remotePatternsに追加(セキュリティ重要)
unoptimized の使いどころ
- 外部の既に最適化済みの画像、SVG、動的に変わる画像 →
unoptimized -
basePathを設定している環境では、ローカル画像(public/配下)にunoptimizedを付けるとbasePathが付与されず 404 になることがある(basePath未使用の環境では起きない)
確認方法
.next/cache/images/ を覗く
ls -la .next/cache/images/
# 各画像ごとにディレクトリができ、配下に最適化済みバイナリ + メタJSON
レスポンスヘッダー
DevTools で /_next/image?url=...&w=...&q=... リクエストを確認:
x-nextjs-cache: HIT
cache-control: public, max-age=604800, must-revalidate
content-type: image/avif
-
x-nextjs-cache: HIT→.next/cache/images/から配信 -
x-nextjs-cache: MISS→ リクエスト時に最適化(次回以降は HIT) -
x-nextjs-cache: STALE→ TTL 切れだが再生成中
CloudFront との二段化
CloudFront 側で /_next/image* をキャッシュさせると、リクエストはこう流れます:
- ブラウザ → CloudFront(HIT) → 完結(ECS に来ない・最速)
- CloudFront(MISS) → Next.js(
x-nextjs-cache: HIT) → CloudFront にキャッシュ(最適化は走らない) - CloudFront(MISS) → Next.js(
x-nextjs-cache: MISS) → 最適化処理(sharp)→ 配信 → 以降キャッシュ
CloudFront を主役にする場合、キャッシュキーの設定を正しくしないとヒット率が崩れる/事故る点に注意します。
-
キャッシュキーに
url/w/qクエリ文字列を含める
/_next/image?url=...&w=...&q=...はクエリで配信物が変わります。これらをキャッシュキーから外すと、別サイズ・別画像が混ざります。 -
⚠️ フォーマット(
Accept)をキャッシュキーに反映する(CDN キャッシュするなら必須) — 一番ハマる
Next.js の画像最適化は リクエストのAcceptヘッダを見て AVIF / WebP を出し分け、レスポンスにVary: Acceptを付けます。ところが CloudFront はVaryを自動では尊重しないため、フォーマット情報をキャッシュキーに入れないと、WebP/AVIF 非対応ブラウザに AVIF を返すような事故が起きます。
生のAcceptはブラウザ/バージョンで多様なので、おすすめは CloudFront Function(viewer-request)でAcceptを3値に正規化した独自ヘッダx-img-formatをキャッシュキーに使う方法です(キャッシュも3バケットに抑えられヒット率が良い):-
Acceptにimage/avifを含む →x-img-format: avif -
Acceptにimage/webpを含む →x-img-format: webp - それ以外(WebP/AVIF 非対応ブラウザ)→
x-img-format: original
設定は 「キャッシュキー」と「オリジン転送」を分けるのがコツ:
ポリシー 含めるもの Cache policy(キャッシュキー) 正規化済み x-img-format+url/w/qOrigin request policy(オリジン転送) 生の Accept+url/w/q(Next.js が実フォーマット判定に使う) -
-
TTL は長めに —
minimumCacheTTLを長くするとオリジンのCache-Control: max-ageが長くなり、CloudFront が長く保持 → MISS 自体が減り、オリジンでの再最適化が減る。 -
invalidation — 同じ URL で元画像を差し替えた場合は CloudFront の invalidation が必要(
/_next/image*等)。
レイヤー4: Redis (Custom Cache Handler) — 共有が必要なときの選択肢
in-memory と Redis、どちらを使う?
use cache の既定の保存先は 各 Next.js プロセス内の in-memory LRU です。そのため Next.js を ECS / Kubernetes など複数インスタンスで運用 すると、各タスクがそれぞれ独立した in-memory キャッシュを持ち、インスタンス間で内容がズレうる(かつ再起動で消える)ということになります。
💡 前提として、以下は 常駐プロセス(
next start/ Docker / ECS) を想定しています。サーバーレス環境では in-memory がリクエスト間で永続しないため、共有が必要なら最初からuse cache: remote/cacheHandlersが前提になります。
ただし「ズレたら必ず困る」わけではなく、要件次第で in-memory のままで十分なケースもあります。
| 状況 | 推奨 |
|---|---|
| 単一インスタンス運用 | in-memory で十分 |
| 複数インスタンスだが、各タスクが個別に再フェッチしても許容できる | in-memory + 短い TTL |
| キャッシュ生成コストが重い(DB集計・外部API多段呼び出し)ので無駄な再計算を避けたい | Redis で共有 |
| タグ無効化を全タスクに反映したい(在庫・公開フラグなど。分散タグ協調の実装が前提) | Redis で共有 |
| 鮮度ズレが致命的(コンテンツ公開直後にどのタスクからアクセスしても同じ結果が必要) | Redis で共有 |
判断軸はざっくり言うと:
- 「タスク間でズレてもいいか」 → YES なら in-memory + 短TTL
- 「即時反映 or 重い処理の再計算を避けたいか」 → YES なら Redis
in-memory のままで運用する場合
cacheLife を短めに設定してインスタンス間のズレを許容範囲に抑えます。レイヤー2で紹介した "use cache"(インライン / ファイル先頭)をそのまま使い、TTL だけ短く調整するのが基本です。
// 関数単位(in-memory・短TTL運用)
import { cacheLife, cacheTag } from "next/cache";
export async function getProducts(category: string) {
"use cache";
cacheLife("shortTerm"); // 例: 60秒程度で expire
cacheTag("products", `products-${category}`);
const res = await fetch(`https://api.example.com/products?category=${category}`);
return res.json();
}
// コンポーネント単位(in-memory・短TTL運用)
export default async function FeaturedBanner() {
"use cache";
cacheLife("shortTerm");
cacheTag("featured-banner");
const banner = await fetch("https://api.example.com/featured").then((r) => r.json());
return (
<section className="banner">
<h2>{banner.title}</h2>
<p>{banner.subtitle}</p>
</section>
);
}
短TTLで「最大でも数十秒」のズレに留めれば、複数インスタンスでも実用上問題にならないケースは多いです。
Redis で共有する場合(Next.js 16 の正しいやり方) [6] [7]
ここが Next.js 16 で大きく変わったポイントです。混同しやすい 2 つの設定を区別してください。
| 設定キー | 対象 | 用途 |
|---|---|---|
cacheHandler(単数)+ cacheMaxMemorySize
|
ISR / ルートハンドラ応答 / 旧 fetch Data Cache | use cache には効きません |
cacheHandlers(複数, { default, remote }) |
use cache / use cache: remote
|
use cache の保存先を差し替えるのはこちら |
つまり「単数 cacheHandler を設定するだけで use cache が Redis 共有になる」というのは 誤りです。use cache を複数インスタンスで共有したいなら、次の 2 つが必要です。
-
next.config.tsでcacheComponents: trueを有効化し、cacheHandlers.remoteに共有ストア(Redis 等)の実装を登録する - 共有したい関数・コンポーネントのコードに
'use cache: remote'ディレクティブを書く(= 「アプリコード変更不要」ではない。素の'use cache'は in-memory 止まりで共有されない)
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
cacheHandlers: {
// 'use cache' 用(省略時は in-memory LRU)
// default: require.resolve("./cache-handler-default.js"),
// 'use cache: remote' 用(Redis 等の共有ストア)
remote: require.resolve("./cache-handler-remote.js"),
},
};
export default nextConfig;
// 共有したい関数には 'use cache: remote' を付ける
import { cacheLife, cacheTag } from "next/cache";
export async function getProducts(category: string) {
"use cache: remote"; // ← 全インスタンスで共有される
cacheLife("shortTerm");
cacheTag("products", `products-${category}`);
const res = await fetch(`https://api.example.com/products?category=${category}`);
return res.json();
}
⚠️ ライブラリの対応状況に注意: かつて定番だった
@neshca/cache-handlerは Next.js 13.5〜14 までの対応(後継の@fortedigital/nextjs-cache-handlerも執筆時点で Next.js 16 /use cacheは部分対応)。@neshcaのonCreation+redis-stringsという旧 API は 単数cacheHandler(ISR 用)向けで、use cacheの共有には使えません。Next.js 16 でuse cache: remoteの Redis バックエンドを用意する場合は、公式cacheHandlersリファレンスの ReadableStream 対応ハンドラ実装(get/set/refreshTags/getExpiration/updateTags)を実装します。
構造
┌──────────────────┐
[ECS Task #1] ─────►│ │
[ECS Task #2] ─────►│ Redis │ ← タグ無効化を共有ストアで管理
[ECS Task #3] ─────►│ (共有キャッシュ) │
└──────────────────┘
すべてのタスクが同じ Redis を見るので:
- どのタスクが処理しても キャッシュは1回だけ生成
- タグ無効化を全タスクに反映できる。ただし自動ではなく、Cache Handler が
updateTags()(無効化を共有ストアへ書く)とrefreshTags()(各リクエスト前に共有ストアからタグ状態を同期)を実装している場合に限る(分散タグ協調)。これが無いと各タスクはローカルのタグ状態しか見ず、片方で破棄しても他タスクは stale を返し続けます。 [8]
注意点
- PII を絶対に Redis 経由のキャッシュに乗せない(複数ユーザー間で共有されるため)
-
cacheTag命名を雑にするとrevalidateTagの範囲が広すぎてヒット率が落ちる - Redis 自体の TTL ではなく Next.js の
cacheLifeで expire を制御(二重TTLを避ける) - 接続エラー時のフォールバック挙動(in-memory に落とすか、ミス扱いにするか)を Cache Handler 側で設計する
確認方法
Redis CLI で直接覗く
redis-cli
> KEYS nextjs:*
> GET nextjs:abc123...
> MONITOR # リアルタイムにキー操作を観察
-
nextjs:プレフィックスで Next.js のキャッシュキーが格納されている -
cacheTagごとのインデックスキーも作られる(実装による)
タグ無効化の伝播確認
- タスク#1 で
revalidateTag('products', 'max')(または Server Action 内のupdateTag('products'))を呼ぶ - タスク#2 のログ(
NEXT_PRIVATE_DEBUG_CACHE=1)で該当タグの再フェッチが走ることを確認 - Redis 上で対応するキー / タグ状態が更新されていることを確認
伝播するのは Cache Handler が分散タグ協調(
updateTags/refreshTags)を実装している場合のみ。実装が無いと他タスクには反映されません。
レイヤー5: TanStack Query(クライアント) [9]
ブラウザ側でサーバー状態をキャッシュするライブラリ。Next.js の Server Component が主軸でも、クライアントでインタラクティブに再フェッチしたいケース で使います。
持ち方
"use client";
import { useQuery } from "@tanstack/react-query";
type Notification = { id: string; title: string };
export function NotificationList({ userId }: { userId: string }) {
const { data } = useQuery({
queryKey: ["notifications", userId],
queryFn: (): Promise<Notification[]> =>
fetch(`/api/notifications/${userId}`).then((r) => r.json()),
staleTime: 60_000, // 60秒間は再フェッチ不要
gcTime: 5 * 60_000, // 5分間メモリ保持
});
return (
<ul>
{data?.map((n) => (
<li key={n.id}>{n.title}</li>
))}
</ul>
);
}
staleTime/gcTimeは TanStack Query v5 の API です(v4 のcacheTimeは v5 でgcTimeに改名)。
Server Component との使い分け
| 用途 | 使うもの |
|---|---|
| 初回描画用のサーバ取得 | Server Component + use cache
|
| ユーザー操作で再フェッチ・楽観更新 | TanStack Query |
| リアルタイム性が必要(無限スクロール等) | TanStack Query |
注意点
-
PII を
queryKeyに含めないこと(DevTools で丸見えになる) - API 通信は必ず Next.js サーバー経由(Route Handler)にして、クライアントから直接バックエンドを叩かない
確認方法
React Query DevTools
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
画面右下のフローティングボタンから、現在保持しているクエリ一覧と状態(fresh / stale / inactive / fetching)を確認できます。
レイヤー6: Browser HTTP Cache & Router Cache
Browser HTTP Cache
Cache-Control / ETag に従ってブラウザがメモリ・ディスクに保持。Next.js のレスポンスヘッダーを正しく設定すれば自動で効きます。
DevTools の Network タブで:
-
Size列が(disk cache)/(memory cache)→ ブラウザキャッシュヒット -
Status列が304 Not Modified→ ETag 検証で再利用
Next.js Router Cache
App Router のクライアント側ナビゲーションで、訪問済みルートのRSCペイロードをキャッシュ。router.refresh() で破棄。明示的な設定はほぼ不要ですが、「戻る」で古いデータが見える原因になることがあるので存在を知っておくと良いです。
キャッシュ確認チートシート
| 確認したいもの | 方法 |
|---|---|
| CloudFront ヒット | DevTools Network: x-cache: Hit from cloudfront
|
Next.js Data Cache (use cache) |
NEXT_PRIVATE_DEBUG_CACHE=1 のログ(既定は in-memory なのでファイルには現れない) |
| Next.js Image Cache |
.next/cache/images/ / Response header x-nextjs-cache: HIT
|
| Redis (Cache Handler) |
redis-cli KEYS ... / MONITOR
|
| TanStack Query | React Query DevTools |
| Browser HTTP Cache | DevTools Network: Size列 (disk cache) / Status 304
|
まとめ
-
キャッシュは多段で重ねる: ①CDN → ②
use cache/ ③Image Cache → ④Redis(共有) → API → DB -
use cacheを使うにはcacheComponents: trueが必須(Next.js 16 の Cache Components 機能) -
Redis は API サーバー側ではなく、
use cacheの共有バックエンドとして使う- 共有は単数
cacheHandlerではなくuse cache: remote+cacheHandlersで行う(コードに'use cache: remote'を書く) - 「タスク間でズレが許せない」「再計算コストが重い」なら共有、そうでなければ in-memory + 短 TTL で十分
- タグ無効化の他タスク伝播は 自動ではなく、分散タグ協調(
updateTags/refreshTags)の実装が前提
- 共有は単数
- PII・下書き・認証情報は共有キャッシュに乗せない
-
use cacheの既定保存先はプロセス内 in-memory LRU(.next/cache/fetch-cache/は旧 fetch/ISR 用で、ここには現れない)-
images/= Image Cache
-
-
確認はレスポンスヘッダー / ログ
-
x-cache(CloudFront) -
x-nextjs-cache(Next.js Image) -
NEXT_PRIVATE_DEBUG_CACHE=1のログ (use cacheData Cache) - DevTools の Size列・Status 304 (ブラウザ)
-
- クライアント側状態は TanStack Query、Server Component と役割分担する
- 更新系は Next.js 16 の
updateTag(read-your-writes)/revalidateTag(tag, profile)(SWR)を使い分ける
正しく組めば、ユーザーへのレイテンシを抑えつつバックエンドの負荷も最小化できます。各レイヤーが「何を守っているのか」を意識して設計しましょう。
参考リンク
本文中の [n] は以下の番号に対応します。
- [1] CloudFront キャッシュ動作(AWS 開発者ガイド) — レイヤー1
-
[2]
cacheComponents— レイヤー2(use cacheの有効化) -
[3]
use cacheディレクティブ — レイヤー2 -
[4]
revalidateTag/updateTag— レイヤー2(タグ破棄) -
[5]
next/image(画像最適化) — レイヤー3 -
[6]
use cache: remoteディレクティブ — レイヤー4 -
[7]
cacheHandlers(use cache用カスタムストア) — レイヤー4 - [8] Self-hosting(Multi-Instance Cache Coordination) — レイヤー4
- [9] TanStack Query — レイヤー5