結論:Next.js 15 で fetch のデフォルトキャッシュ挙動が変わった
Next.js App Router で fetch がキャッシュされない最大の原因は、v14 → v15 アップグレード時のデフォルト動作の破壊的変更だ。
| バージョン | fetch のデフォルト | 意味 |
|---|---|---|
| Next.js 13 / 14 | force-cache |
キャッシュあり(HIT するまで再リクエストしない) |
| Next.js 15 | no-store |
キャッシュなし(毎回リクエストが発生する) |
npm update next でバージョンを上げた直後に「キャッシュが壊れた」「APIが毎回叩かれる」と感じた場合は、まずこの変更を疑うこと。
こんな症状が出たらキャッシュ設定を見直す
| 症状 | よくある原因 |
|---|---|
| ページロードのたびに外部APIリクエストが発生する | Next.js 15 へのアップグレード後のデフォルト変更 |
revalidate を設定したのにキャッシュされない |
cache: 'no-store' との競合、または設定の優先順位の誤解 |
| 開発環境ではキャッシュされるが本番でされない | export const dynamic や revalidate の設定漏れ |
| ISR が動かない・ページが再生成されない | revalidatePath / revalidateTag の呼び忘れ |
| 認証付きAPIのレスポンスがキャッシュされない | Authorization ヘッダー付き fetch は自動的にキャッシュ除外される |
| POST リクエストのレスポンスがキャッシュされない | POST は仕様上キャッシュ対象外 |
原因1:Next.js 15 で fetch のデフォルトが no-store になった
なぜデフォルトが変わったのか
Next.js 13/14 では fetch を拡張し、デフォルトで force-cache として動作させていた。これはビルド時に外部データを取得してキャッシュしたい SSG/ISR の用途では便利だったが、「意図せずデータが古くなる」「キャッシュが思わぬ挙動を起こす」という混乱を招いた。
Next.js 15 ではこれを改め、fetch のデフォルトを標準の Web API と同じ no-store に戻した。これにより動作の予測可能性が向上したが、キャッシュを前提にしていたコードは毎回 API を叩くようになる。
症状の確認方法
# まず Next.js バージョンを確認する
cat package.json | grep '"next"'
# "next": "^15.0.0" なら要注意
// app/page.tsx(Next.js 14 では自動でキャッシュされていたコード)
export default async function Page() {
// Next.js 14: デフォルトで force-cache → キャッシュされる
// Next.js 15: デフォルトで no-store → 毎回リクエストが飛ぶ
const data = await fetch('https://api.example.com/data').then(r => r.json())
return <div>{data.title}</div>
}
対策:明示的に cache オプションを指定する
// キャッシュを有効にする(Next.js 14 以前の動作を維持したい場合)
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache',
}).then(r => r.json())
// 一定時間でキャッシュを無効にする(ISR 相当)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }, // 1時間ごとに再取得
}).then(r => r.json())
// キャッシュしない(毎回最新を取得)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store',
}).then(r => r.json())
原因2:Authorization ヘッダー付き fetch はキャッシュされない
なぜ認証付きリクエストがキャッシュされないのか
Next.js は Authorization ヘッダーや Cookie ヘッダーが含まれる fetch をデフォルトでキャッシュから除外する。ユーザーごとに異なるデータが返る可能性があるため、安全のためにキャッシュ対象外になっている。
// このfetchはキャッシュされない(Authorization ヘッダーがあるため)
const data = await fetch('https://api.example.com/me', {
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'force-cache', // ← これを指定してもキャッシュされない
}).then(r => r.json())
対策:認証データは unstable_cache でキャッシュする
ユーザー固有のデータをキャッシュしたい場合は unstable_cache を使い、ユーザーIDをキャッシュキーに含めることでパーユーザーキャッシュを実現できる。
import { unstable_cache } from 'next/cache'
// ユーザーIDをキーにしてキャッシュ
const getUserData = unstable_cache(
async (userId: string) => {
const data = await fetch(`https://api.example.com/users/${userId}`, {
headers: { Authorization: `Bearer ${({}).API_SECRET}` },
}).then(r => r.json())
return data
},
['user-data'], // キャッシュキーのプレフィックス
{ revalidate: 300, tags: ['user'] } // 5分間キャッシュ
)
// 使用例
export default async function UserPage({ params }: { params: { id: string } }) {
const data = await getUserData(params.id) // params.id がキャッシュキーに追加される
return <div>{data.name}</div>
}
原因3:Route Handler のキャッシュルールは Server Component と異なる
Route Handler は GET 以外キャッシュされない
app/api/xxx/route.ts の Route Handler では、GETリクエストのみがデフォルトでキャッシュ対象となる(かつ Next.js 15 ではこれも no-store がデフォルト)。POST/PUT/DELETE はキャッシュ対象外だ。
// app/api/data/route.ts
// GET: キャッシュ設定が有効
export async function GET() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 },
}).then(r => r.json())
return Response.json(data)
}
Route Handler 自体をキャッシュから除外したい場合
// app/api/realtime/route.ts
// Route Handler 全体を動的にする
export const dynamic = 'force-dynamic'
export async function GET() {
const data = await fetch('https://api.example.com/live').then(r => r.json())
return Response.json(data)
}
原因4:revalidate の競合・優先順位を理解していない
fetch レベル vs ページレベルの revalidate
revalidate はページ(セグメント)レベルと fetch レベルの両方で設定できるが、より短い値が優先される。
// app/page.tsx
export const revalidate = 3600 // ページ全体: 1時間
export default async function Page() {
// この fetch は 60秒で再取得(ページの1時間より短いため優先される)
const fast = await fetch('https://api.example.com/hot', {
next: { revalidate: 60 },
}).then(r => r.json())
// この fetch は ページのrevalidate(1時間)に従う
const slow = await fetch('https://api.example.com/static').then(r => r.json())
return <div>{fast.value} / {slow.value}</div>
}
cache: 'no-store' と revalidate を同時に指定してはいけない
// NG:競合する設定
const data = await fetch('https://api.example.com/data', {
cache: 'no-store', // ← キャッシュしない
next: { revalidate: 60 }, // ← 60秒でキャッシュを再取得する
// この組み合わせはエラーになる(Next.js がワーニングを出す)
}).then(r => r.json())
// OK:どちらか一方を選ぶ
const noCache = await fetch('https://api.example.com/data', {
cache: 'no-store',
}).then(r => r.json())
const withRevalidate = await fetch('https://api.example.com/data', {
next: { revalidate: 60 },
}).then(r => r.json())
原因5:fetch 以外のデータ取得はキャッシュされない
ORM・DB クライアントのクエリはキャッシュ対象外
Prisma・Drizzle・直接DBクエリなど、fetch を使わないデータ取得は Next.js のデータキャッシュの対象外だ。
// これはキャッシュされない(fetch を使っていないため)
const users = await prisma.user.findMany()
const posts = await db.select().from(postsTable)
対策:unstable_cache で fetch 以外のデータをキャッシュする
import { unstable_cache } from 'next/cache'
import { prisma } from '@/lib/prisma'
const getCachedPosts = unstable_cache(
async () => {
return await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
})
},
['posts-list'],
{
revalidate: 300, // 5分間キャッシュ
tags: ['posts'], // タグを使ってオンデマンド再検証を可能にする
}
)
export default async function BlogPage() {
const posts = await getCachedPosts()
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
)
}
オンデマンド再検証(ISR)が動かない時の対処
revalidatePath / revalidateTag の正しい使い方
Server Actions や Route Handler から呼び出してキャッシュを手動で無効化する。
// app/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
await db.insert(posts).values({
title: formData.get('title') as string,
})
// 特定パスのキャッシュを無効化
revalidatePath('/blog')
// タグでキャッシュを無効化(unstable_cache の tags と対応する)
revalidateTag('posts')
}
// app/api/revalidate/route.ts(Webhook からのオンデマンド再検証)
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const { tag, secret } = await request.json()
if (secret !== ({}).REVALIDATE_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(tag)
return Response.json({ revalidated: true, now: Date.now() })
}
Next.js 14 → 15 移行チェックリスト(fetch キャッシュ関連)
# バージョン確認
cat package.json | grep '"next"'
- [ ] fetch のデフォルト変更を確認: v15 では
no-storeがデフォルト。意図してキャッシュしたい fetch にはcache: 'force-cache'またはnext: { revalidate: N }を明示する - [ ]
cache: 'no-store'とrevalidateの競合を除去: 両方指定している fetch を修正する - [ ] 認証付き fetch のキャッシュ戦略を変更:
Authorizationヘッダー付きはunstable_cacheに移行する - [ ] DB クエリのキャッシュを
unstable_cacheで明示: fetch 以外のデータ取得はすべて手動でキャッシュ設定が必要 - [ ] Route Handler の
dynamic設定を確認: 動的にしたい Handler にはexport const dynamic = 'force-dynamic'を追加する - [ ]
revalidatePath・revalidateTagの呼び出しを確認: データ更新後にキャッシュが無効化されているか確認する
キャッシュ設定の選び方まとめ
| ユースケース | 推奨設定 |
|---|---|
| 毎回最新データが必要(ユーザー固有のデータなど) | cache: 'no-store' |
| 一定時間でデータを更新(ISR 相当) | next: { revalidate: N } |
| ビルド時にデータ取得・変化しないデータ | cache: 'force-cache' |
| ユーザーIDごとにキャッシュを分けたい | unstable_cache にユーザーIDをキーとして渡す |
| DB クエリ・ORM のキャッシュ | unstable_cache でラップする |
| CMSのデータをWebhookで再検証したい | tags 付き unstable_cache + revalidateTag |
まとめ
- Next.js 15 へのアップグレードで fetch のデフォルトが
no-storeに変更された。意図してキャッシュするにはcache: 'force-cache'またはnext: { revalidate: N }を明示する必要がある。 Authorization/Cookieヘッダー付きの fetch はキャッシュされない仕様。ユーザー固有データをキャッシュしたい場合はunstable_cacheを使う。- fetch 以外のデータ取得(Prisma 等)は
unstable_cacheでラップしなければキャッシュされない。 cache: 'no-store'とnext: { revalidate: N }は同時に指定できない。どちらか一方を選ぶこと。- オンデマンド再検証は
revalidatePath/revalidateTagを使い、データ更新と同時にキャッシュを無効化するのが基本パターン。
