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 を改善した記事に書いたので、そちらも参考になるはず。キャッシュは「効かなくて困る」より「効いていることに気づかなくて困る」ほうが圧倒的に厄介だった。