Next.js 16のキャッシュ設計を理解する

暗黙のキャッシュという落とし穴

Next.js 13でApp Routerが導入されたとき、fetch()はデフォルトでキャッシュされるようになった。フレームワークが「賢く」パフォーマンスを最適化してくれる——そういう設計思想だった。

しかし、この「親切な」デフォルトは多くの開発者を混乱させた。

// Next.js 14以前:これはキャッシュされる
const data = await fetch('/api/users')

// キャッシュを無効化したい場合は明示的に指定
const data = await fetch('/api/users', { cache: 'no-store' })

問題は、開発者が意図せずキャッシュされたデータを表示してしまうことだった。データベースを更新したのに画面に反映されない。APIの応答が変わったはずなのに古いデータが表示される。いわゆる「over-caching」と呼ばれる現象だ。

この暗黙的なキャッシュは「静かなUXキラー」として開発者コミュニティで問題視されるようになった。

エスケープハッチの乱立

Next.jsチームはこの問題に対処するため、さまざまな制御オプションを追加していった。

// セグメントレベルの設定
export const dynamic = 'force-dynamic'
export const revalidate = 0
export const fetchCache = 'force-no-store'

dynamicruntimefetchCachedynamicParamsrevalidate...。これらのセグメントレベル設定は「エスケープハッチ」として機能したが、その数が増えるにつれて複雑性も増していった。

さらに、fetch()以外のデータ取得(データベースへの直接アクセスなど)を制御するためのunstable_cache()も追加されたが、APIの使い勝手は良いとは言えなかった。

開発者からのフィードバックは明確だった。キャッシュの挙動が予測できない。どの設定が優先されるのかわからない。デバッグが困難。

フレームワークのキャッシュ思想を比較する

この問題を理解するために、他のフレームワークがキャッシュをどう扱っているか見てみよう。

Remix:Web標準への回帰

Remixは「Use the Platform」という哲学を掲げている。キャッシュについても、フレームワーク独自の抽象化を最小限に抑え、HTTPのCache-Controlヘッダーに委ねるアプローチを取る。

// Remix: loaderでCache-Controlヘッダーを返す
export function loader() {
  return json(data, {
    headers: {
      'Cache-Control': 'public, max-age=300, stale-while-revalidate=60'
    }
  })
}

1997年から存在するHTTP標準を使うことで、CDNやブラウザのキャッシュ機構とシームレスに連携できる。開発者が学ぶべきは新しいAPIではなく、Web標準そのものだ。

SvelteKit:慎重な暗黙キャッシュ

SvelteKitはload関数の結果を自動的にキャッシュするが、その姿勢は慎重だ。

公式ドキュメントには興味深い記述がある:「load関数の結果をキャッシュして再利用することは『悪いデフォルト』になりうる。コンポーネント内でデータが変更された場合にバグが発生しやすく、メモリとパフォーマンスのトレードオフは意識的に行われるべきだ」

SvelteKitはdependsinvalidateによる明示的な無効化を推奨しつつ、aggressive なキャッシュには警告を発している。

Next.js 14から15へ:揺り戻し

Next.js 15では、この反省を踏まえてデフォルトの挙動が変更された。

これは正しい方向への一歩だったが、まだ「キャッシュしたい場合にどうするか」という問いへの答えは洗練されていなかった。

Next.js 16:「2つの概念」への収束

Next.js 16の設計思想は明確だ。公式ブログ「Our Journey with Caching」では、キャッシュ制御を2つの概念だけに収束させると宣言している。

  1. <Suspense> - 非同期境界を定義する
  2. use cache - キャッシュ対象を明示する

従来の複雑なセグメント設定は、この2つの概念に置き換えられる。

// Next.js 16: 明示的なキャッシュ宣言
'use cache'

export default async function Page() {
  const data = await fetch('/api/data')
  return <div>{data}</div>
}

この設計の核心は「cache by default」から「cache by consent」への転換だ。開発者が明示的にuse cacheと宣言しない限り、何もキャッシュされない。

use cacheの設計を深掘りする

use cacheディレクティブは、一見シンプルだが内部では洗練された仕組みが動いている。

キャッシュキーの自動生成

キャッシュエントリのキーは、以下の要素から自動生成される。

キャッシュキー = Build ID + Function ID + 引数 + クロージャ変数
要素 説明
Build ID ビルドごとに一意。新しいデプロイで全キャッシュが無効化
Function ID 関数の位置とシグネチャのハッシュ
引数 関数に渡された引数(シリアライズ可能なもの)
クロージャ変数 外側スコープから参照された変数

開発者がキャッシュキーを手動で管理する必要はない。コンパイラが自動的に適切なキーを生成する。

クロージャ変数の暗黙的キャプチャ

ここがuse cacheの興味深いポイントだ。外側のスコープから参照された変数は、暗黙的に引数として扱われ、キャッシュキーに含まれる。

async function Component({ userId }: { userId: string }) {
  const getData = async (filter: string) => {
    'use cache'
    // キャッシュキーに含まれるもの:
    // - userId(クロージャからキャプチャ)
    // - filter(引数)
    return fetch(`/api/users/${userId}/data?filter=${filter}`)
  }

  return getData('active')
}

この例では、userIdgetData関数の引数ではないが、クロージャを通じてキャプチャされているため、自動的にキャッシュキーの一部となる。異なるuserIdfilterの組み合わせごとに、別のキャッシュエントリが作成される。

これは一見「暗黙的」に見えるかもしれない。しかし、コードを読めばuserIdが使われていることは明らかだ。キャッシュキーの構成要素が「見える」という点で、従来の暗黙的キャッシュとは本質的に異なる。

動的値の制限:意図的な不便さ

use cacheの内部では、cookies()headers()などの動的APIを直接呼び出すことはできない。

// ❌ これはエラーになる
async function BadCached() {
  'use cache'
  const cookieStore = cookies() // エラー
  return <div>{cookieStore}</div>
}

// ✅ 正しい方法:値を引数として渡す
async function GoodCached({ userId }: { userId: string }) {
  'use cache'
  const data = await fetch(`/api/users/${userId}`)
  return <div>{data}</div>
}

export default async function Page() {
  const cookieStore = await cookies()
  const userId = cookieStore.get('userId')?.value
  return <GoodCached userId={userId} />
}

この制限は意図的なものだ。動的な値をキャッシュ関数に渡すには、明示的に引数として渡す必要がある。これにより:

  1. キャッシュキーに何が含まれるかが明確になる
  2. 動的データと静的データの境界が可視化される
  3. 誤って動的データをキャッシュしてしまうリスクが減る

「不便さ」が安全性を担保している例だ。

Compositionパターン:childrenの特別扱い

use cacheコンポーネントに渡されたchildrenは、「読まずに通す」ことで動的部分をキャッシュから除外できる。

async function CachedWrapper({ children }: { children: ReactNode }) {
  'use cache'
  return (
    <div className="wrapper">
      <header>Cached Header</header>
      {children} {/* この部分はキャッシュに影響しない */}
    </div>
  )
}

export default function Page() {
  return (
    <CachedWrapper>
      <DynamicComponent /> {/* 毎回実行される */}
    </CachedWrapper>
  )
}

この設計により、ページの静的な「殻」をキャッシュしつつ、動的なコンテンツはリクエストごとに生成するという柔軟な構成が可能になる。

cacheLifeによる有効期限制御

use cacheと組み合わせて使うcacheLife関数で、キャッシュの有効期限を制御できる。

'use cache'
import { cacheLife } from 'next/cache'

export default async function BlogPage() {
  cacheLife('days')
  const posts = await getBlogPosts()
  return <div>{/* render posts */}</div>
}

プリセットの選び方

Next.jsは用途に応じたプリセットを提供している。

プリセット stale revalidate expire 用途
seconds 30秒 1秒 1分 リアルタイムデータ(株価、スコア)
minutes 5分 1分 1時間 頻繁に更新(SNSフィード)
hours 5分 1時間 1日 定期更新(在庫、天気)
days 5分 1日 1週間 日次更新(ブログ記事)
weeks 5分 1週間 30日 週次更新(ポッドキャスト)
max 5分 30日 1年 ほぼ不変(法的文書、アーカイブ)

staleはクライアントがサーバーに確認せずにキャッシュを使える時間、revalidateはサーバーがバックグラウンドで再生成する間隔、expireはキャッシュの最大保持期間を表す。

カスタムプロファイルの定義

プリセットで足りない場合は、next.config.jsでカスタムプロファイルを定義できる。

// next.config.js
module.exports = {
  cacheComponents: true,
  cacheLife: {
    biweekly: {
      stale: 60 * 60 * 24 * 14,  // 14日
      revalidate: 60 * 60 * 24,  // 1日
      expire: 60 * 60 * 24 * 14, // 14日
    },
  },
}

この転換が示すもの

「賢いデフォルト」の限界

Next.js 13-14のキャッシュ設計は、フレームワークが「賢く」振る舞おうとした結果だった。開発者の代わりにパフォーマンス最適化を行い、明示的な設定なしに高速なアプリケーションを実現する——そういう理想があった。

しかし現実には、この「賢さ」が予測不能な挙動を生み、開発者の信頼を損なった。

これはオプトインとオプトアウトに関する重要な教訓だ。フレームワークのデフォルト設定は、多くのユースケースで「正しい」ことが求められる。しかし、キャッシュのような複雑な領域では、何が「正しい」かはアプリケーションによって大きく異なる。オプトアウト方式(デフォルトでキャッシュ)は一見便利だが、ユーザーの意図しない挙動を招きやすい。

明示性とReactの思想

Next.js 16のuse cacheは、明示性と暗黙性におけるReactの立場と整合している。

Reactは状態管理において明示性を重視してきた。useStateuseEffectuseMemo——これらはすべて、開発者が意図を明示的に宣言するAPIだ。暗黙的な状態変更やライフサイクルの自動実行は避けられている。

use cacheもこの流れにある。キャッシュという副作用を持つ操作を、明示的なディレクティブで宣言する。何がキャッシュされるかはコードを読めばわかる。

Web標準への回帰とフレームワークの役割

Remixが示した「Use the Platform」という哲学、SvelteKitの慎重なアプローチ、そしてNext.js 16の明示的キャッシュ。これらに共通するのは、フレームワークが「魔法」を減らす方向に進んでいることだ。

フレームワークの役割は、開発者の代わりに判断を下すことではない。開発者が良い判断を下せるように、適切な抽象化と明確なAPIを提供することだ。

Next.js 16のキャッシュ設計は、この教訓を体現している。

参考リンク

抽出された概念