メインコンテンツへスキップ
#Web制作 約7分で読めます

Next.js App Router のキャッシュに嵌まった話。fetch を no-store にしても本番のデータが古い

Next.js 15でfetchのデフォルトはキャッシュなしになったはずが、本番でデータが更新されない。原因はData CacheではなくFull Route Cacheだった。ダッシュボード開発でハマった4層キャッシュの切り分けと、revalidate・unstable_cacheの使い所を整理する。

Next.js(App Router)で案件管理のダッシュボードを作っている。スクレイパーが定期的に案件データを更新して、ダッシュボードで一覧を見る、という構成だ。ローカルでは何の問題もなかったのに、本番にデプロイした途端、一覧がいつまで経っても更新されないという現象に当たった。

Next.js 15 から fetch はデフォルトでキャッシュされなくなったはず。実際、対象は Next.js 15 系のプロジェクトだ。なのにデータが古い。ここから「Next.js のキャッシュは1層じゃない」という当たり前の事実を、体で理解することになった。

前提: Next.js 15 で fetch のデフォルトは変わった

まず前提の整理。Next.js 14 までの App Router は fetch() がデフォルトで積極的にキャッシュされる仕様で、「データが更新されない」の苦情が多発していた。Next.js 15(2024年10月リリース)でこれが逆転し、fetch はデフォルトでキャッシュされなくなった。GET の Route Handler も同様にデフォルト非キャッシュになっている。

// Next.js 15 以降: デフォルトはキャッシュなし
const res = await fetch('https://api.example.com/jobs')

// キャッシュしたい時だけ明示的にオプトイン
const res2 = await fetch('https://api.example.com/categories', {
  cache: 'force-cache',
  next: { revalidate: 3600 },
})

だから「15 ならもうキャッシュには嵌まらない」と思っていた。甘かった。

原因は Data Cache ではなく Full Route Cache だった

App Router のキャッシュは大きく4層ある。サーバー側に Request Memoization・Data Cache・Full Route Cache、クライアント側に Router Cache。Next.js 15 の変更で「キャッシュなし」になったのは主に Data Cache(fetch の結果) の話であって、ページのレンダリング結果(Full Route Cache)は別物だ。

自分のページは cookies()searchParams も使わないシンプルな一覧ページだった。この場合、Next.js はそのルートを静的ルートと判断してビルド時にプリレンダリングする。fetch がキャッシュされない設定でも、その fetch が走るのはビルド時の1回だけ。生成された HTML が Full Route Cache として配信され続けるので、デプロイ時点のデータで凍結される。

つまり「fetch はキャッシュされていない。ただしページごとビルド時に焼き固められていた」。cache: 'no-store' を明示的に付けても静的プリレンダリング自体は止まらなかった(Next.js 15 では fetch のオプションだけではルートは動的にならない)ので、最初は本気で混乱した。

対処はルートセグメント設定で、ルートを動的にするか、再生成の間隔を与えるかの二択。

// app/jobs/page.tsx

// 案1: 毎リクエストでレンダリング(ダッシュボード向き)
export const dynamic = 'force-dynamic'

// 案2: ISR的に60秒ごとに再生成(一覧・ブログ向き)
export const revalidate = 60

自分のダッシュボードは自分しか見ないので、素直に force-dynamic にした。アクセスが多い公開ページなら revalidate で間隔を持たせる方がいい。「Next.js 15 なのにデータが古い」の切り分けは、まず「そのルートは静的か動的か」を疑う。ビルドログの ○ (Static) / ƒ (Dynamic) マークを見れば一発でわかる。

DB 直読みには fetch のキャッシュ設定が効かない

もうひとつ嵌まりかけたのがこれ。ダッシュボードのデータソースは外部 API ではなく SQLite で、Server Component から直接クエリしている。当然だが、fetch のキャッシュオプションは fetch にしか効かない。DB クエリの結果をキャッシュしたければ unstable_cache を使うことになる。

一覧は force-dynamic で毎回引くとして、全期間の集計みたいな重いクエリまで毎回回すのは無駄なので、そこだけ関数単位でキャッシュした。

import { unstable_cache } from 'next/cache'

const getJobStats = unstable_cache(
  async () => db.prepare('SELECT status, COUNT(*) AS n FROM jobs GROUP BY status').all(),
  ['job-stats'],           // キャッシュキー
  { tags: ['jobs'], revalidate: 600 },
)

データ更新側(Server Action や Route Handler)で revalidateTag('jobs') を呼べば、時間を待たずに即座に無効化できる。時間ベース(revalidate)とイベントベース(tags)を両方付けておいて、「普段は10分キャッシュ、更新操作があったら即反映」にするのが使いやすかった。

ひとつ注意点。キャッシュキーを雑に付けると別のクエリ同士でキーが衝突する。「全件取得」と「featured だけ取得」に同じ ['jobs'] キーを付けて、片方の結果がもう片方に化ける、というのを開発中に一度やった。関数の引数はキーに自動で含まれるが、引数なしの別関数はキー文字列で区別するしかない。

なお名前の通りこの API は将来 'use cache' ディレクティブへ置き換わる方向だが、'use cache' は本記事時点でまだ experimental 扱いなので、安定版で動かすなら現状は unstable_cache が現実解だと思っている。

最後の一層、Router Cache にも一応触れておく

サーバー側を直しても「更新操作のあとブラウザバックすると古い画面が出る」ことがある。これはクライアント側の Router Cache の仕業だ。自分のケースでは、更新系を Server Action にして revalidatePath('/jobs') を呼ぶ形にしたら、クライアント側も一緒に更新されて解決した。Server Action まわりの実装はNext.js 15 Server Actions のフォームエラーハンドリングに別でまとめている。

切り分けの手順だけ覚えて帰る

今回の学びを1行ずつにするとこうなる。

  • データが古い時は、まずどの層のキャッシュかを特定する。ビルドログの Static/Dynamic 表示が最初の手がかり
  • Next.js 15 の「デフォルトでキャッシュなし」は fetch(Data Cache)の話。静的プリレンダリング(Full Route Cache)は別枠で、ルートセグメント設定で制御する
  • DB 直読みは unstable_cache + tags。fetch のオプションは効かない

レンダリング戦略そのものを見直すなら、静的と動的を1ページ内で混ぜる方向の話をStreaming SSR で FCP を改善した記事に書いたので、そちらも参考になるはず。キャッシュは「効かなくて困る」より「効いていることに気づかなくて困る」ほうが圧倒的に厄介だった。

#Next.js #App Router #キャッシュ #revalidate #unstable_cache
シェア