← 記事一覧に戻る

Next.js App Router で fetch キャッシュが効かない・毎回リクエストが飛ぶ時の原因と対策【v14 / v15 / revalidate / unstable_cache】

結論: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 dynamicrevalidate の設定漏れ
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' を追加する
  • [ ] revalidatePathrevalidateTag の呼び出しを確認: データ更新後にキャッシュが無効化されているか確認する

キャッシュ設定の選び方まとめ

ユースケース 推奨設定
毎回最新データが必要(ユーザー固有のデータなど) 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 を使い、データ更新と同時にキャッシュを無効化するのが基本パターン。