「Next.js 15にアップデートしたら、なぜかデータが常に古いままだ...」「以前はキャッシュされていたはずなのに、毎回APIが叩かれてパフォーマンスが落ちた...」
Next.js 14まで当然のように使っていたfetchのキャッシング挙動が、Next.js 15で大きく変わったことで、多くの開発者がこのような問題に直面しています。特に、意図せずデータが常に古いままになる問題や、逆にキャッシュされずにパフォーマンスが低下する落とし穴は、アプリケーションの安定性とUXに直結します。
この記事では、Next.js 15におけるfetchの新しいキャッシング挙動と、revalidateによる再検証の仕組みを徹底解説します。具体的なコード例とともに、データ鮮度とパフォーマンスを両立させるための実践的なキャッシング戦略、そして開発者が陥りやすいミスとその回避策を学ぶことができます。
Next.js 15におけるfetchのキャッシング挙動の変更点
Next.js 15のリリースは、データフェッチとキャッシングのパラダイムに大きな変化をもたらしました。このセクションでは、Next.js 14以前との違いと、新しいデフォルト挙動について解説します。
デフォルトで「キャッシュされない」挙動への変更
Next.js 14までは、fetchリクエストとGET Route Handlerはデフォルトでキャッシュされる挙動でした。これは、特に意識せずともある程度のパフォーマンス最適化が図れる一方で、「なぜかデータが更新されない」といったデバッグしにくい問題を引き起こすこともありました。
しかし、Next.js 15ではこのデフォルト挙動が真逆となり、fetchリクエストとGET Route Handlerはデフォルトでキャッシュされません。つまり、開発者はキャッシュを明示的にオプトインする必要があります。この変更は、アプリケーションのキャッシング戦略をより意図的かつ明確に設計することを促します。
キャッシュを明示的にオプトインする方法
Next.js 15でfetchリクエストをキャッシュするには、以下のいずれかの方法で明示的にオプトインする必要があります。
-
fetchオプションで{ cache: 'force-cache' }を指定する:
これは、特定のfetchリクエストのみを静的にキャッシュしたい場合に有効です。// Next.js 15: 明示的に静的キャッシュをオプトイン async function getStaticData() { const res = await fetch('https://api.example.com/data', { cache: 'force-cache' }); if (!res.ok) { throw new Error('Failed to fetch data'); } return res.json(); } -
ルートセグメントに
export const dynamic = 'force-static'を設定する:
これは、そのルートセグメント(ページやレイアウト)内のすべてのデータフェッチを静的にキャッシュしたい場合に利用します。// app/products/page.tsx export const dynamic = 'force-static'; // このページ内のすべてのfetchは静的にキャッシュされる export default async function ProductsPage() { const res = await fetch('https://api.example.com/products'); // ... }
この変更により、Next.js 14から15にアップグレードした際に、以前暗黙的にキャッシュされていたデータが常に再フェッチされるようになり、パフォーマンス低下や意図しないデータ更新が発生する可能性があるので注意が必要です。
revalidateオプションによるデータ再検証
デフォルトでキャッシュされないNext.js 15において、データの鮮度とパフォーマンスを両立させるためには、revalidateオプションを適切に活用することが重要です。このセクションでは、時間ベースとタグベースの再検証方法を解説します。
時間ベースの再検証: next: { revalidate: ... }とexport const revalidate
時間ベースの再検証は、指定した時間間隔でキャッシュされたデータをバックグラウンドで更新する「Stale-While-Revalidate (SWR)」パターンを実装します。これにより、ユーザーには常に高速にコンテンツを提供しつつ、定期的に最新のデータに更新できます。
-
fetchリクエストごとにnext: { revalidate: <seconds> }を設定する:
特定のfetchリクエストのキャッシュ有効期間を秒単位で指定します。// fetchリクエストごとに再検証間隔を設定 const res = await fetch('https://api.example.com/products', { next: { revalidate: 3600 } }); // 1時間ごとに再検証 if (!res.ok) { throw new Error('Failed to fetch products'); } -
ルートセグメント全体に
export const revalidate = <seconds>を設定する:
そのルートセグメント(ページやレイアウト)内のすべてのデータフェッチに、指定した再検証間隔を適用します。これはIncremental Static Regeneration (ISR) の核となる機能です。// app/products/page.tsx export const revalidate = 3600; // このページ内のすべてのfetchは1時間ごとに再検証される export default async function ProductsPage() { const res = await fetch('https://api.example.com/products'); // ... }revalidate: falseはキャッシュを無期限に保持し、revalidate: 0はキャッシュを無効にします。
タグベースのオンデマンド再検証: next: { tags: [...] }とrevalidateTag
時間ベースの再検証だけでは、データの変更が即座にUIに反映されない場合があります。CMSの更新やユーザー操作によってデータが変更された際に、手動でキャッシュを無効化したい場合は、タグベースの再検証が有効です。
-
fetchリクエストにタグを付ける:
next: { tags: ['your-tag-name'] }オプションを使用して、fetchリクエストに任意のタグを付けます。// fetchリクエストにタグを付ける const res = await fetch('https://api.example.com/products', { next: { tags: ['products'] } }); if (!res.ok) { throw new Error('Failed to fetch products'); } -
Server Actionで
revalidateTagを使用してキャッシュを無効化する:
データの更新(作成、編集、削除など)を行うServer Action内で、revalidateTag('your-tag-name')を呼び出すことで、そのタグに関連付けられたキャッシュのみを無効化できます。'use server'; import { revalidateTag } from 'next/cache'; export async function updateProduct(productId: string, newName: string) { // データベース更新ロジック // await db.product.update({ where: { id: productId }, data: { name: newName } }); console.log(`Product ${productId} updated to ${newName}`); // 仮のログ revalidateTag('products', 'max'); // 'products'タグのキャッシュを無効化 (stale-while-revalidate) return { success: true }; }revalidateTagの第二引数に'max'を指定すると、SWRセマンティクスが適用され、古いコンテンツを提供しつつバックグラウンドで新しいコンテンツをロードします。
パスベースのオンデマンド再検証: revalidatePath
特定のパスのキャッシュを無効化したい場合は、revalidatePath関数を使用します。
'use server';
import { revalidatePath } from 'next/cache';
export async function submitComment(articleSlug: string, comment: string) {
// コメント保存ロジック
// await db.comments.create({ articleSlug, comment });
revalidatePath(`/articles/${articleSlug}`); // 特定の記事ページのキャッシュを無効化
return { success: true };
}
ただし、revalidatePathは一度に1つのパスしか再検証できないため、複数のパスをまとめて無効化したい場合はrevalidateTagの使用を検討してください。
Next.js 15 Canary: dynamicIO APIとuse cacheディレクティブ
Next.js 15では、より高度なキャッシング制御を可能にするdynamicIO APIとuse cacheディレクティブがCanary版で導入されました。これらを活用することで、コンポーネントや関数レベルでキャッシュを細かく制御し、パフォーマンスをさらに最適化できます。
dynamicIOの有効化
dynamicIO APIを使用するには、まずnext.config.mjsでexperimental.dynamicIOフラグをtrueに設定する必要があります。
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
dynamicIO: true, // Next.js 15 Canaryで新しいキャッシングAPIを有効化
},
};
export default nextConfig;
use cacheディレクティブによる関数・コンポーネントレベルのキャッシュ
'use cache'ディレクティブは、関数やコンポーネントをキャッシュ対象としてマークします。これにより、その関数が返す値やコンポーネントのレンダリング結果がキャッシュされ、再利用されるようになります。
// app/lib/data.ts
import { cacheLife } from 'next/cache';
export async function getProductsFromDB() {
'use cache'; // この関数をキャッシュする
cacheLife('hours'); // キャッシュの有効期間を「時間」プロファイルに設定
console.log('Fetching products from DB...'); // キャッシュヒット時は表示されない
// データベースクエリのシミュレーション
return new Promise(resolve => setTimeout(() => resolve([
{ id: 1, name: 'Product A' },
{ id: 2, name: 'Product B' }
]), 1000));
}
// app/page.tsx (または他のServer Component)
import { getProductsFromDB } from './lib/data';
export default async function HomePage() {
const products = await getProductsFromDB();
return (
<div>
<h1>Home</h1>
{products.map((product: any) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
この例では、getProductsFromDB関数がキャッシュされるため、HomePageが複数回レンダリングされても、実際のデータベースアクセスはcacheLifeで指定された期間に一度しか行われません。
cacheLifeとcacheTagによる詳細なキャッシュ制御
use cacheスコープ内で、cacheLifeとcacheTag関数を使用することで、キャッシュの有効期間とタグ付けをより細かく制御できます。
-
cacheLife: キャッシュされたデータの有効期間を制御します。'seconds','minutes','hours'のような組み込みプロファイル名、または{ stale: ..., revalidate: ..., expire: ... }のようなカスタム設定オブジェクトを受け入れます。 -
cacheTag: キャッシュエントリにタグを付けるために使用されます。revalidateTagと組み合わせることで、特定のタグに関連付けられたキャッシュエントリをまとめて無効化できます。
これらのAPIはまだCanary版であり、将来的に変更される可能性があるため、公式ドキュメントで最新情報を確認することが重要です。
Next.js 15 キャッシングでよくある落とし穴と回避策
Next.js 15でのキャッシングは強力ですが、その挙動を理解していないと予期せぬ問題に遭遇することがあります。ここでは、よくあるハマりどころと、それらを回避するための実践的なアドバイスを紹介します。
1. Next.js 14からのアップグレード後にデータが常に最新になる/パフォーマンス低下
-
ハマりどころ: Next.js 15では
fetchがデフォルトでキャッシュされなくなったため、以前のバージョンで暗黙的にキャッシュされていたデータが常に再フェッチされ、パフォーマンスが低下したり、意図せずデータが常に最新になったりします。 -
回避策: 以前のキャッシュ挙動を維持したい場合は、既存の
fetchリクエストに{ cache: 'force-cache' }を明示的に追加するか、ルートセグメントにexport const dynamic = 'force-static'を設定して静的にマークします。
2. 開発モード (next dev) でのキャッシュ挙動の不一致
- ハマりどころ: 開発モードではページは常にオンデマンドでレンダリングされ、キャッシュされないため、本番環境でのキャッシュ挙動を正確にテストできません。これにより、開発環境では問題なく動作するように見えても、本番環境でキャッシュの問題が発生することがあります。
-
回避策: キャッシュ挙動は必ず
next build && next startで本番ビルドを実行してテストします。
3. Router Cacheによるクライアント側の古いデータ表示
- ハマりどころ: クライアント側のRouter Cacheが古いデータを表示し続けることがあります。特に、データの変更(作成、更新、削除)後にUIが最新の状態を反映しない場合に発生します。Next.js 15以降、動的ページはクライアントでデフォルトでキャッシュされなくなりましたが、静的ページは依然としてキャッシュされます。
-
回避策:
- Server Actionでデータ変更後に
router.refresh()を呼び出してクライアント側のRouter Cacheをクリアします。 - コンポーネントを更新タイムスタンプに紐付けた
keyで再マウントします。 -
revalidateTagやrevalidatePathを使用して、関連するキャッシュを無効化します。
- Server Actionでデータ変更後に
4. 非常に長い、または無限のrevalidate期間の設定
-
ハマりどころ:
revalidate: 31536000(1年)やInfinityのような非常に長い、または無限のrevalidate期間を設定すると、Vercelなどのプラットフォームで壊れたキャッシュレスポンスが提供されたり、データが取得できなかったりする問題が発生する可能性があります。 -
回避策: 実際的な間隔(例:1日
86400秒以下)に設定し、必要に応じてオンデマンド再検証と組み合わせることを検討してください。
5. 複数のrevalidatePathが同期的に動作しない
-
ハマりどころ:
revalidatePathは一度に1つのパスしか再検証できないため、複数のパスを連続して再検証しようとすると期待通りに動作しない場合があります。 -
回避策: 複数のパスを再検証する必要がある場合は、代わりに
revalidateTagを使用して、関連するすべてのリソースをタグで管理し、まとめて無効化します。
Next.js 15における最適なキャッシング戦略とベストプラクティス
Next.js 15では、キャッシング戦略をより意識的に設計することが求められます。ここでは、データ鮮度、パフォーマンス、スケーラビリティのバランスを取りながら、効果的なキャッシングを実装するためのベストプラクティスを紹介します。
キャッシュ戦略の明示化とレイヤーの理解
Next.js 15ではキャッシュがデフォルトで無効になったため、どのデータをどのくらいの期間キャッシュするかを明示的に決定することが重要です。これにより、予期せぬ古いデータの問題を減らし、アプリケーションの予測可能性を高めます。
Next.jsのキャッシュは、Request Memoization、Data Cache、Full Route Cache、Router Cacheの4つの異なるレイヤーで構成されています。これらのレイヤーがどのように連携し、どのデータがどこでキャッシュされるかを理解することが、キャッシュの問題を診断し解決するために不可欠です。
データフェッチの場所とuse clientの最小化
可能な限りサーバーコンポーネントでデータをフェッチすることが推奨されます。これにより、以下のメリットが得られます。
- バックエンドデータリソースへの直接アクセス
- 機密情報のクライアントへの露出防止
- クライアントとサーバー間の通信の削減
- 複数データフェッチの単一ラウンドトリップ化
- クライアント-サーバー間のウォーターフォールの削減
厳密に必要な場合を除き、use clientの使用は避けるべきです。クライアントコンポーネントは、アプリケーションの大部分をラップするのではなく、インタラクティブなUIの小さな部分として使用するように設計しましょう。
キャッシュの粒度とrevalidateの適切な使用
コンテンツの種類と鮮度要件に応じて、適切なキャッシュ粒度と再検証方法を選択します。
-
ページ/レイアウトレベルのキャッシュ: 頻繁に変わらないコンテンツ(例:ブログ記事、製品リスト)を持つページ全体やレイアウトをキャッシュするのに適しています。
export const revalidate = 60やexport const dynamic = 'force-static'を使用します。 -
コンポーネント/関数レベルのキャッシュ (Next.js 15 Canary): 特定のコンポーネントやデータ取得関数のみをキャッシュしたい場合に
'use cache'ディレクティブを使用します。これにより、他の部分は動的に保ちながら、パフォーマンスを最適化できます。 -
時間ベースの再検証: 鮮度が多少遅れても許容されるデータ(ニュース記事、製品カタログなど)には
revalidateオプションを使用し、stale-while-revalidateパターンを活用します。 -
オンデマンド再検証: ユーザーの操作やバックエンドの更新によって即座にUIを更新する必要があるデータ(ユーザープロファイルの更新、権限変更など)には
revalidateTagやrevalidatePathを使用します。
キャッシュのタグ付けによる効率的な管理
next: { tags: [...] }オプションやcacheTag関数 (Next.js 15 Canary) を使用して関連するデータにタグを付けることで、特定のグループのキャッシュを効率的に無効化できます。これは、CMSの更新など、特定のコンテンツが変更されたときに非常に役立ちます。
開発時のデバッグ
キャッシュの問題をデバッグする際には、以下の方法が有効です。
- タイムスタンプを追加してログを記録し、いつデータがフェッチされたかを確認します。
-
x-nextjs-cacheレスポンスヘッダーを確認して、どのキャッシュレイヤーが問題を引き起こしているかを特定します。 - ブラウザの開発者ツールでネットワークタブを確認し、リクエストがキャッシュから提供されているか、再フェッチされているかを確認します。
自己ホスト型アプリケーションでのキャッシュ
Vercel以外の環境でNext.jsアプリケーションを自己ホストする場合、デフォルトのファイルシステムキャッシュがコンテナ環境やマルチインスタンス環境では機能しないことがあります。このような場合は、Redisなどの外部キャッシュハンドラを実装することを検討し、キャッシュの一貫性を保つ必要があります。
まとめ
Next.js 15におけるfetchのキャッシング挙動の変更は、アプリケーションのパフォーマンスとデータ鮮度を制御するための大きな機会を提供します。
-
デフォルト挙動の理解: Next.js 15では
fetchがデフォルトでキャッシュされないため、明示的なオプトインが必要です。 -
revalidateの活用: 時間ベース(next: { revalidate: ... }、export const revalidate)とタグベース(next: { tags: [...] }、revalidateTag)の再検証を適切に使い分け、データ鮮度とパフォーマンスを両立させましょう。 -
Canary APIの探索:
dynamicIOAPIや'use cache'ディレクティブ(Canary版)は、よりきめ細やかなキャッシング制御を可能にします。 - 落とし穴の回避: 開発モードでの挙動の違いやRouter Cacheの問題など、よくあるハマりどころとその回避策を理解しておくことが重要です。
-
戦略的な設計: アプリケーションの要件に応じて、キャッシュの粒度、データフェッチの場所、
revalidateの適用方法を戦略的に設計し、ベストプラクティスに従うことで、予測可能で高性能なNext.jsアプリケーションを構築できます。
これらの知識を活かし、Next.js 15で強力なキャッシング戦略を実装してください。公式ドキュメントで最新の情報を常に確認し、ベストプラクティスを取り入れることをお勧めします。