every Tech Blog

株式会社エブリーのTech Blogです。

Next.js 16 のキャッシュとどう付き合うか ― 実装と運用のあいだで考えたこと

Next.js 16 のキャッシュとどう付き合うか ― 実装と運用のあいだで考えたこと

目次

はじめに

こんにちは、開発本部の黒髙です。普段はデリッシュキッチンの開発に携わっています。

現在、運用中のWebアプリケーションをNext.jsに移行する検討を進めており、その過程で避けて通れないテーマのひとつがキャッシュでした。Next.jsの機能を調べてみると思ったよりも複雑で、理解が難しいと感じました。しかし、アプリの要件上、サーバーリソースの負荷を抑える観点ではある程度キャッシュを考慮すべきであり、完全に無視して運用することは現実的ではありません。

キャッシュの事故でよく耳にするのが、更新したはずのデータが古いままユーザーに届き続ける「stale」と呼ばれる状態です。本記事では細かいパフォーマンス調整よりも、「予期せぬ stale による事故のリスク/その原因となる実装ミスをどう減らすか」という観点を中心に考えます。

まず現状のキャッシュ機構を3層で整理したうえで、方針転換を繰り返してきた歴史と、実装時の注意点を検証も含めて述べていきます。最後に、これらを踏まえてチーム開発でどう運用するかを、コーディングエージェント(AI)との共存も含めて考察します。

Next.js のキャッシュを整理する

Next.js のキャッシュは、Router Cache / Full Route Cache / Data Cache といった似た響きの用語と、use cache / cacheLife / revalidateTag など複数のAPIが絡み合っており、公式ドキュメントでも全体を掴むのは難しいと感じました。私は、キャッシュの動作場所に注目して、以下の3層で整理するのがわかりやすいのではないかと考えました。

  • ブラウザ(Router Cache)
  • CDN・Edge(HTTP Cache)
  • サーバー(Data Cache = use cache

Webアプリのライフサイクル全体で言えば、バックエンドサーバー自身のキャッシュなども存在しますが、本記事では扱いません。とはいえ、Next.jsを取り巻くキャッシュだけでもWebアプリのライフサイクルの多くをカバーしていることがわかります。

ブラウザ(Router Cache)

クライアントのブラウザ上で動作するキャッシュで、<Link> などによるページ遷移をスムーズに見せるために内部的に保持されるものです。staleTimesで挙動を調整できますが、基本的には値を細かく設定する層ではない印象です。

<Link> 経由で遷移する先は、ビューポート付近に入ったタイミングで裏側で prefetch され、Reactサーバコンポーネントのpayloadがブラウザ内に保持されます。

import Link from 'next/link';

// 自動 prefetch(デフォルト)
<Link href="/recipes/123">生姜焼きのレシピ</Link>

// prefetch を止めたい場合
<Link href="/recipes/123" prefetch={false}>生姜焼きのレシピ</Link>

明示的に破棄したい場面では、クライアント側で router.refresh() を呼びます。

Router Cacheの詳細は、Prefetchingを参照してください。

CDN・Edge(HTTP Cache)

CDN と Web アプリサーバーの間で働く、HTTPリクエストベースのキャッシュです。前提として、後述のサーバーキャッシュ(use cache, cacheLife)とは別物であり、それらが自動で同期されないことに注意が必要です。

Next.js はルートの分類(○ Static / ◐ PPR / ƒ Dynamic)に応じて Cache-Control を自動で書き分けます。アプリ側から直接ヘッダを書くことはなく、ビルド時に上書きされます。

○ Static   → s-maxage=<revalidate>, stale-while-revalidate=<expire - revalidate>
◐ PPR      → private, no-cache, no-store, max-age=0, must-revalidate
ƒ Dynamic  → 同上

補足: static / dynamic / PPR Next.js はルートを、ビルド時に確定できる static、リクエストごとに描画する dynamic、静的な shell に動的部分を後追いで差し込む PPR (Partial Prerendering) の3種類に分類します。Cache Components を有効にした Next.js 16 の主要機能です。

また、Next.js 独自のヘッダ(x-nextjs-cache, x-nextjs-prerender, x-nextjs-postponed, x-nextjs-stale-time など)も配信されますが、セルフホスティングですべてを扱おうとすると複雑性が増すため、あまり現実的ではありません。

サーバー(Data Cache = use cache

ユーザーが意図的に管理する、サーバー内でのキャッシュです。use cache で宣言します。Cache Components という概念自体は Next.js 16 から導入されたもので、寿命(TTL)は cacheLife、タグによる明示 invalidation は cacheTag + revalidateTag という2系統のコントロール手段が用意されています。

関数・コンポーネント単位で 'use cache' を付けてキャッシュし、寿命は cacheLife で宣言します。

// 関数単位
import { cacheLife } from "next/cache";

export async function fetchRecipe(id: string) {
  "use cache";
  cacheLife("hours"); // 組み込みプリセット: 1時間ごとに再検証
  const { data } = await apiClient(`/recipes/${id}`);
  return data;
}
// コンポーネント単位
async function RecipeList() {
  "use cache";
  cacheLife("hours"); // 1時間ごとに再検証
  const recipes = await getRecipes();
  return (
    <ul>
      {recipes.map((r) => (
        <li key={r.id}>{r.name}</li>
      ))}
    </ul>
  );
}

時間ではなく明示的な契機で更新したい場合は、cacheTag + revalidateTag を組み合わせます。

// 書く側: タグを打つ
import { cacheTag } from "next/cache";

export async function fetchRecipe(id: string) {
  "use cache";
  cacheLife("days"); // 1日ごとに再検証
  cacheTag(`recipe-${id}`);
  const { data } = await apiClient(`/recipes/${id}`);
  return data;
}
// 更新契機側: 無効化する(Next.js 16 は2引数必須)
import { revalidateTag } from "next/cache";

export async function POST() {
  revalidateTag("recipe-1", "days");
  return Response.json({ ok: true });
}

ただし revalidateTag が効くのはサーバ層の Data Cache のみで、CDN が前段にあれば別途キャッシュを削除する必要があります。3層のキャッシュはそれぞれ独立した寿命と無効化手段を持つため、層をまたいだ無効化には個別の対応が要ります。

なお 'use cache' には、スコープ違いの 'use cache: private' / 'use cache: remote' もあります(詳細は 公式ドキュメント: use cache を参照)。

キャッシュに関する思想と変更の歴史

Next.js のキャッシュの理解が難しいとされるもう一つの要因として、破壊的ともいえる仕様変更・方針転換がこれまで何度か行われてきた歴史も関係しています。

同じ1行の fetch が各バージョンでどう振る舞うかを整理すると、次のようになります。

バージョン const res = await fetch('/api') の挙動 明示するなら
Next.js 13(初期) 暗黙にキャッシュされる(デフォルト無期限) { cache: 'no-store' } で opt-out
Next.js 14 同上(+ Full Route Cache / Data Cache の概念整理) 同上
Next.js 15 毎回リクエスト(uncached) に反転 { next: { revalidate: N } } で opt-in
Next.js 16 同上。ただし 'use cache' で明示宣言した関数のみキャッシュされる 関数に 'use cache' + cacheLife(...)

同じ1行が時期によって「無期限キャッシュ」「毎回リクエスト」「そもそもキャッシュされない」と意味を変えてきています。この履歴を知らずに古いサンプルコードをコピーすると、そのまま事故につながる危うさがあります。

1. App Router 初期 — 暗黙的なキャッシュ

App Router 初期は、fetch がデフォルトで暗黙にキャッシュされる挙動でした。しかもデフォルトでは TTL が設定されず、再検証を明示しない限りキャッシュされたまま残り続けるという仕様になります。

2. Next.js 15 — uncached by default への揺り戻し

Next.js 15 では、デフォルトが「キャッシュ」から「uncached」へ真逆に転換されました(公式ブログ: Next.js 15 RC)。同じ1行の fetch の意味が v14 → v15 で正反対になるため、既存コードの挙動が意図せず変わる可能性があり、移行には慎重な確認が必要だったと思われます。

3. Next.js 16 — Cache Components による explicit / composable 化

現在の中心思想であり、'use cache' を opt-in 寄りにして明示させる方針です(公式ブログ: Next.js 16)。v14 の「暗黙」、v15 の「uncached デフォルト」に対して、v16 は 「'use cache' と書いた関数だけが、cacheLife で寿命を明示したうえでキャッシュされる」という、キャッシュの有無と寿命をすべてコード上で宣言するモデルです。

歴史に対してどう立ち向かうか

単に使うだけでなく思想や背景まで知ると、キャッシュとPPR方針の関連のような縦の流れが見えて、仕様理解が深まります。とはいえ、Next.js 側が今後どういう振る舞いをしてくるかを予測するのは難しいのも事実です。

そこで、「キャッシュは明示的に書く」「デフォルト挙動に頼らない」の2点を基本にします。暗黙的なコードは移行時に予期せぬ事故を起こす可能性が高く、次に仕様が変わったときに真っ先に壊れるのも「デフォルト挙動に依存したコード」であり、そのリスクはできるだけ回避しておきたいです。

実装中に気づいた挙動と対策

本章で扱う気づきは次の2つです。

  • 気づき①: dynamic 判定で意図せず private / no-store が付与される
  • 気づき②: layout.tsxcacheLife を持つと子ページにも伝搬する

それぞれの遭遇経緯と検証結果を示したうえで、最後に 実践するための型(build ログ / HTTP ヘッダ / 自動テスト) を独立セクションにまとめます。

気づき①: dynamic 判定で意図せず private / no-store が付与される

遭遇したきっかけ

Next.js 16 への移行を検討する中で、PPR の挙動を試していたときのことです。「TOPページの大半を 'use cache' で静的に保ちつつ、<FavoriteInfo />(cookie からお気に入りIDを読む小さなコンポーネント)だけ <Suspense> で分離する」という構成で実ヘッダを確認したら、Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate が返ってきて想定外でした。このまま本番に出すと CDN のヒット率が期待どおり出ず、オリジン負荷が上がる可能性があります。

公式ドキュメント(CDN Caching)には static / dynamic それぞれの挙動は書かれているものの、両者が混ざったルートで HTTP レイヤに返る Cache-Control は明示されていません。移行判断の材料として、最小構成で挙動を切り分けました。

検証(3つのページの比較)

以下の3ルートをローカルの Next.js 16(Cache Components 有効)で用意して比較しました。

// /case-a : 完全 static(ビルド時に結果が決まる。動的要素なし)
async function getStaticPayload() {
  "use cache";
  cacheLife("hours");
  return {
    /* ... */
  };
}
export default async function StaticOnlyPage() {
  const data = await getStaticPayload();
  return <main>{/* ... */}</main>;
}

// /case-b : mixed PPR(static な RecipeList と、リクエストごとに変わる FavoriteInfo が同居)
export default function Page() {
  return (
    <main>
      <RecipeList /> {/* 'use cache' 付き = static 扱い */}
      <Suspense fallback={<div>loading favorite list...</div>}>
        <FavoriteInfo />{" "}
        {/* cookies() を読む = リクエストごとに変わる動的部分 */}
      </Suspense>
    </main>
  );
}

// /case-c : ページ全体が dynamic(動的なcookies() 読み取りだけ)
async function DynamicBody() {
  const favoriteId = (await cookies()).get("favoriteId")?.value ?? "empty";
  return <p>{favoriteId}</p>;
}
export default function DynamicOnlyPage() {
  return (
    <main>
      <Suspense fallback={<div>loading...</div>}>
        <DynamicBody />
      </Suspense>
    </main>
  );
}

pnpm build を実行すると、3ルートの分類は以下のようになります。

Route (app)               Revalidate  Expire
┌ ◐ /case-b                       1h      1d
├ ◐ /case-c
└ ○ /case-a                       1h      1d

next start を起動して実際に返る Cache-Control を確認すると次の通りです。

ルート構成 分類 Cache-Control
/case-a(use cache のみ) ○ Static s-maxage=3600, stale-while-revalidate=82800
/case-b(use cache + Suspense 内 cookies) ◐ PPR private, no-cache, no-store, max-age=0, must-revalidate
/case-c(shell + Suspense 内 cookies) ◐ PPR private, no-cache, no-store, max-age=0, must-revalidate

ルート内に dynamicな要素(cookies / headers / connection)が1か所でも混ざると、HTTP レイヤは一律 no-store になり、cacheLife で設定した値は効きません。

気づき②: layout.tsx が TTL を持つと子ページにも伝搬する

検証の中で、コンテンツがほぼ空のページの Cache-Control を確かめたところ、「空ページなので CDN に永続(= s-maxage=31536000)でキャッシュできるはず」という予測が外れ、s-maxage=3600 が返ってきました。原因は (main)/layout.tsxcacheLife('hours')(1時間)を持つ関数を内部で呼んでいたことでした。これでは、静的に返したいページが1時間ごとに再検証される構成になってしまいます。

// app/(main)/layout.tsx
import { fetchRecipes } from "@/lib/api/recipes"; // 内部で 'use cache' + cacheLife('hours')

export default async function MainLayout({ children }) {
  const recipes = await fetchRecipes();
  return (
    <>
      {/* sidebar など */}
      {children}
    </>
  );
}

実ビルド出力

Route (app)                Revalidate  Expire
┌ ○ /recipes                      1h      1d     ← (main) 配下
└ ○ /about                                       ← (static) 配下、永続

実際に返るヘッダを確認すると次の通りです。

/recipes : Cache-Control: s-maxage=3600, stale-while-revalidate=82800    ← (main) 配下
/about   : Cache-Control: s-maxage=31536000                              ← (static) 配下、永続

ルートの最終 Cache-Control は page + layout + 配下で呼ばれる use cache 関数の最短 cacheLife で決まるため、layout 側に短い TTL があるとそちらが優先されます。 この挙動を意識しながら、layout ごとにキャッシュ寿命が自然に分かれるように設計することと、next build の出力で全ルートの Revalidate 列を確認する習慣を付けることが、手堅い備えになると感じました。

実測で対策する

上に挙げた気づきはいずれも実測で検知できる種類のものです。ここでは、自分が取り入れている4つの型をまとめます。

A. next build のログを読む

next build の最終出力にルート一覧が出ます。これが一次資料です。

Route (app)                Revalidate  Expire
┌ ○ /                             10m      1y
├ ○ /about
├ ○ /categories                   10m      1y
├ ◐ /categories/[id]
├ ◐ /recipes/[id]
└ ○ /terms

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML + dynamic streaming
ƒ  (Dynamic)            server-rendered on demand

読み方の要点は次の通りです。

  • が付いたルートは HTTP 層では必ず no-store になります。cacheLife は内部にしか効きません。
  • Revalidate 列は そのルート全体で呼ばれる cacheLife のうち最も短い値 を示すので、想定より短ければ layout のキャッシュ関数が原因になっていることが多いです(気づき②)。

B. HTTP ヘッダを直接見る

Cache-Control や Next.js 独自のヘッダは、ブラウザの DevTools(Network タブ)や curl / httpie など、どの HTTP クライアントでも確認できます。

見るべきヘッダの組み合わせは例えば以下の通りです。

見えたヘッダ 結論
s-maxage=... が含まれる 完全 static、CDN で効く
private, no-store が含まれる PPR か dynamic、CDN 効かない

C. テストで Cache-Control を監視する

Cache-Control の分類を自動テストにしておけば、意図せず分類が変わった瞬間に気付くことができます。Playwright で書くなら、例えば以下の通りです。

test("main routes return expected Cache-Control", async ({ request }) => {
  const table = [
    { path: "/case-a", match: /s-maxage=\d+/ },
    { path: "/case-b", match: /no-store/ },
    { path: "/case-c", match: /no-store/ },
  ];
  for (const { path, match } of table) {
    const res = await request.get(path);
    expect(res.headers()["cache-control"]).toMatch(match);
  }
});

D. 補足: 内部 Data Cache の hit/miss

NEXT_PRIVATE_DEBUG_CACHE=1 を付けて起動すると、サーバー側のキャッシュ挙動をサーバーログから見ることもできます。

$ NEXT_PRIVATE_DEBUG_CACHE=1 pnpm start
...
FileSystemCache: get /index APP_PAGE false       ← 初回 miss
use-cache: Resume Data Cache entry found [...]
FileSystemCache: get /index APP_PAGE true        ← 以降 hit

チーム開発を見据えたキャッシュ運用ルール

ここまでで、Next.js のキャッシュ構造と歴史、実装で出会った気づきを整理してきました。歴史からは「明示的に書く」「デフォルト挙動に頼らない」、気づきからは「局所視点では誤りやすい」という性質を引き出しました。ここからは、それらを踏まえてチーム開発で運用していくためにはどういう方針を取るべきかを考えます。

人間同士のチーム開発でも、コーディングエージェント(AI)に書かせる場合でも、同じ理由でミスをしてしまうことがあります。実際、気づき② のケースを AI にコードから予測させてみたところ、人間と同じように外していました。キャッシュ層はファイルをまたいで合成されるため、局所視点では必然的に誤る性質を持っていると推測されます。

そのため、チーム開発でも AI が関わる場合でも、次の4点に注意して開発していきたいと考えています。

  • 書き方を縛る: どこに何を書くかを固定し、選択肢を減らす
  • 機械的に検知する: ESLint / build ログ / 自動テストで違反を落とす
  • ルールを明文化する: AGENTS.md / CLAUDE.md に方針を残す
  • 豊富な機能より保守性: 意図せぬ変更を引き起こさない選択を優先する

以下、この4つの柱を Next.js のキャッシュ運用に当てはめた具体例を示します。

書き方を縛る

選択肢を狭めることは、複雑さを避けて実装者の迷いを減らしたり、予期せぬ変更を防ぐといった保守運用面でのメリットがあります。一方で細かい制御や最適化の機会を失ってしまうため、トレードオフを要件によって見極める必要があります。

TTLプロファイルを活用し、選択肢を増やしすぎない

Next.js 組み込みのプリセット(hours, days など)に加えて、next.config.ts で自前でプロファイル定義することもできます。 これまでの本文では組み込みのプリセットを使ってきましたが、チームで運用する場合は自前のプロファイルを少数だけ許容する方針が良さそうです。

cacheLife: {
  'api-default': { revalidate: 600 },    // 10 分
  'api-long':    { revalidate: 10800 },  // 3 時間
}

プロファイルを絞り込むと、「期待値はこの範囲で回る」というメンタルモデルがチーム内で共有されます。選択肢を狭めることで細かい制御の機会は失いますが、複雑さを避ける観点も必要です。

TTL 設定か invalidation か、どちらか統一する

前半で触れた通り、キャッシュ更新の方針には大きく2種類あります。

  • TTL 型: 全 fetch に cacheLife を付けて時間で更新する
  • Invalidation 型: cacheTag + revalidateTag で、CMS の webhook などの明示的な契機に合わせて無効化する

こちらも同様に、両方を組み合わせてより最適化させる実装を取ることも可能です。しかし、どちらで更新されるかがコードを読むだけでは分からなくなり、判断が難しい領域が増えるといったデメリットも存在します。そのため、今回はTTL型だけに統一する方針をとっています。

// lib/api/recipes.ts
export async function fetchRecipe(id: string) {
  "use cache";
  cacheLife("api-default"); // 10 分で background revalidate
  const { data } = await apiClient(`/recipes/${id}`);
  return data;
}

書き方と場所を統一する

データ取得は lib/api/<domain>.ts に集約し、page では呼ぶだけにします。page / layout / route で 'use cache' を直接書かないようにします。

lib/api/
  client.ts          ← fetch 共通層 (timeout / retry / log)
  recipes.ts         ← 全関数に 'use cache' + cacheLife
  categories.ts      ← 同上
  curations.ts       ← 同上
// app/(main)/page.tsx
import { fetchRecipes } from "@/lib/api/recipes";

export default async function HomePage() {
  const recipes = await fetchRecipes(); // cache はデータ層が知っている
  return <HomeView recipes={recipes} />;
}

page 側がキャッシュの寿命を意識しない、lib/api だけ読めば寿命が分かる、という切り分けにします。キャッシュ関連の変更をするときも、lib/api/ 配下だけを読めば判断できる状態にしておくのが狙いです。

機械的に検知する

より厳密にしたい場合、ESLint の no-restricted-syntax で機械的に縛ることもできます。以下はキャッシュのプロファイル名を制限するコード例です。

// eslint.config.mjs の抜粋イメージ
const ALLOWED = ['api-default', 'api-long'];

// cacheLife はホワイトリスト外のプロファイル名 / custom options を禁止
{
  selector: `CallExpression[callee.name='cacheLife'] > Literal[value!=/^(${ALLOWED.join('|')})$/]`,
  message: `cacheLife は ${ALLOWED.join(' / ')} のみ使用可`,
},
{
  selector: "CallExpression[callee.name='cacheLife'] > ObjectExpression",
  message: 'cacheLife に custom options を直書きしない',
},

機械的な制約として、前章で紹介した「確認の型」(next build のログ、Cache-Control ヘッダ、自動テスト)をCIに組み込んで検知する仕組みを作る、という選択肢も挙げれられます。

ルールを明文化する

Next.js のキャッシュ仕様は版ごとに大きく変わってきたので、AI エージェントは古いバージョンの書き方をしたり、逆に便利そうな新機能を差し込む可能性があります。そもそもそれらを提案させないために、ドキュメントで方針を明示しておくことは、基本的ではありますが重要です。

プロジェクトの CLAUDE.md では、例えば以下のように記述しています。

### データ取得 (エージェント向け)

- データ取得ロジックは `lib/api/` に集約(Single Source of Truth)
- SC → lib/api/ の関数を `use cache` 付きで直接呼び出す
- ISR は使わない。キャッシュは `use cache` で TTL 管理
  (デフォルト `api-default` = 10 分、一部 `api-long` = 3 時間)
- cacheLife はプロジェクト定義のプロファイルのみ使用。preset ('hours', 'days' 等) や
  custom options は使わない

ローカルではなくプロジェクトでファイル管理することで、これらのルールをそのままチームの共通認識として採用することが可能です。

豊富な機能より保守性

Next.js には便利な機能が豊富に用意されていますが、使うほど仕様変更の影響範囲が広がり、コードを読む際の迷いも増えます。自分自身が多くの機能を使いこなして最適化を頑張ることは魅力的に見えますが、「使わずに済むなら使わない」という決断も必要です。豊富さより簡潔さに倒すほうが、長期的には事故を減らすと感じています。

おわりに

今回の整理を振り返ると、Next.js のキャッシュと付き合う上で重要だと感じた点が自然と見えてきました。まずはブラウザ・CDN・サーバーの3層で構造を捉えること。仕様変更の振れ幅が大きい領域なので、デフォルト挙動に依存しすぎないこと。そして保守性や移植性を優先した簡潔なコードを、チームのルールとして縛ること。このあたりが、今回の整理で見えてきたことです。

振り返ると、実装時の気づきとチーム運用への落とし込みのあいだを行き来しながら、Next.js のキャッシュとどう付き合うかを考える機会になりました。層を意識して明示的に書き、ルールで縛るという地味な積み重ねが結局一番効くのだと感じています。

参考文献