Next.js の Data Fetching とキャッシング (App Router)
1. 概要
Next.js App Router は server 上で fetch の結果を自動的にキャッシュして、バックエンド負荷を軽減しページを高速化します。しかし、このキャッシング機構は通常の HTTP キャッシュとは異なり、十分に理解しないと「古いデータが更新されない」や反対に「どのページも毎回最初からレンダリングされて非常に遅い」といった状況に陥りやすくなります。
本記事では Next.js の 4 つのキャッシュレイヤ、fetch の再検証(時間ベースと tag ベース)、そして dynamic rendering を解説します。読み終わるころには、各種ページ(static、ISR、dynamic)に適切な fetch 戦略を選択できるようになり、キャッシュが「頑固に」機能しない場合のデバッグも可能になるでしょう。
注記: 本記事は App Router(Next.js 13 以降、推奨は 14/15)に適用されます。Pages Router は異なる機構(
getStaticProps、getServerSideProps)を使用するため、本記事の対象外です。
2. Next.js の 4 つのキャッシュレイヤ
Next.js には 4 つの独立したキャッシング機構があり、混同しやすいため注意が必要です:
| 機構 | 位置 | 目的 |
|---|---|---|
| Request Memoization | Server、1 回の render 内 | 同じ request 内の重複した fetch コマンドをまとめる |
| Data Cache | Server、request 間で永続 | fetch の結果を長期的にキャッシュし、再検証可能 |
| Full Route Cache | Server、ビルド時 | static route の HTML + RSC payload をキャッシュ |
| Router Cache | Client、セッション内 | ナビゲーション結果をキャッシュして back/forward を高速化 |
本記事では Data Cache に焦点を当てます。これが fetch のオプションで直接制御できる部分だからです。
3. Fetch のデフォルト:「無期限」キャッシュ
Server Component では、fetch は Next.js によってパッチされ、デフォルトで自動的にキャッシュされます:
// app/products/page.tsx
async function getProducts() {
const res = await fetch("https://api.shop.example.com/products");
if (!res.ok) throw new Error("商品 API を呼び出せません");
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p: { id: string; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
fetchにcacheを渡さない → Next.js はデフォルトでforce-cacheを使用します。- 結果は Data Cache に保存され、その後のすべての request で再利用されます。再検証するまで自動的に失効しません。
- このページは Full Route Cache に含まれます:ビルド時に HTML が生成され、static page として配信されます。
これが多くの人を驚かせる理由です:backend のデータを更新してもページが変わらない — Next.js はこれを static data として扱っているからです。
4. cache オプションでキャッシュを制御
常に最新のデータを取得したい場合は no-store を使用します:
async function getOrderStatus(orderId: string) {
const res = await fetch(
`https://api.shop.example.com/orders/${orderId}`,
{ cache: "no-store" }
);
return res.json();
}
cache: "no-store"は、このリクエストの Data Cache を完全に無効化します。- このリクエストを含むルートは dynamic としてマークされ、毎回再レンダリングされます。
- 継続的に変化するデータに適しています:処理中の注文、リアルタイム価格、ダッシュボード。
注記: ページ内の 1 つ の
fetchがno-storeを使用するだけで、そのページ全体が dynamic rendering に切り替わります — 同じページ内の他のfetchは依然としてforce-cacheを使用していても。
5. 時間ベースの再検証(ISR)
キャッシュは保持したいが、N 秒後に自動的に更新したい場合(Incremental Static Regeneration)は、next.revalidate を使用します:
async function getCategories() {
const res = await fetch(
"https://api.shop.example.com/categories",
{ next: { revalidate: 300 } } // 5 分
);
return res.json();
}
revalidate: 300は、データが 300 秒後に「古い」と見なされることを意味します。- 300 秒経過後の最初のリクエストでも古いデータを返します(stale-while-revalidate)が、Next.js はバックグラウンドで新しい fetch をトリガーします。
- 次のリクエストで新しいデータを受け取ります。
ファイルの先頭で変数をエクスポートすることで、ルート全体に適用できます:
// app/blog/[slug]/page.tsx
export const revalidate = 300;
export default async function BlogPost({ params }: { params: { slug: string } }) {
const res = await fetch(`https://api.blog.example.com/posts/${params.slug}`);
return <article>{/* ... */}</article>;
}
export const revalidateは、ルート内のすべてのfetchに適用されます。ただし、個別に異なるrevalidateを宣言しているfetchはその値を優先します。- 1 つの
fetchが独自のnext.revalidateを宣言している場合、そちらがそのfetchに対して優先されます。
6. Tag ベースの再検証:revalidateTag
時間ベースの再検証は定期的に変わるコンテンツには適していますが、データが変わった直後に再検証したい場合(例:管理者が商品価格を更新した直後)には使用できません。その場合は cache tags を使用します。
// app/products/[id]/page.tsx
async function getProduct(id: string) {
const res = await fetch(
`https://api.shop.example.com/products/${id}`,
{ next: { tags: [`product-${id}`] } }
);
return res.json();
}
管理者が商品を更新したとき、Route Handler または Server Action で revalidateTag を呼び出します:
// app/actions/update-product.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updateProduct(id: string, formData: FormData) {
await fetch(`https://api.shop.example.com/products/${id}`, {
method: "PATCH",
body: formData,
});
revalidateTag(`product-${id}`); // このタグを持つキャッシュを削除
}
tagsは fetch のキャッシュエントリに「ラベル」を付けます。revalidateTag(tag)は、そのタグを持つすべての Data Cache エントリを削除します。どのルートで使用されているかは関係ありません。- 次のアクセスでは新しいデータが fetch され、新しいタグでキャッシュされます。
これが最も強力な機構です。キャッシュを「いつ無効化するか」という論理を「どのページがデータを表示するか」から分離できるため、Spring Cache や Redis の cache tagging に似ています。
7. Path ベースの再検証:revalidatePath
タグを使用しない場合は、ルートパスで直接再検証できます:
"use server";
import { revalidatePath } from "next/cache";
export async function publishPost(slug: string) {
// ... API publish を呼び出す ...
revalidatePath(`/blog/${slug}`); // このページのみ
revalidatePath("/blog", "page"); // またはリスト全体
}
revalidatePath("/blog/[slug]")は、その特定のルートの Full Route Cache + Data Cache を削除します。- 2 番目の引数(
"page"または"layout")は、そのレイアウトを使用する子ルートも再検証するかどうかを決定します。 revalidateTagとは異なります:revalidatePathは 特定のルート に結び付いているのに対し、tag は複数のルートで使用できます。
注記:
revalidateTag/revalidatePathは Server Action または Route Handler 内でのみ実行できます。Client Component から直接呼び出すことはできません。
8. Dynamic rendering:キャッシュが無意味になるとき
特定の API は Next.js を自動的に dynamic rendering に切り替えさせ、Full Route Cache をスキップします:
import { cookies, headers } from "next/headers";
export default async function AccountPage() {
const sessionToken = (await cookies()).get("session")?.value;
const userAgent = (await headers()).get("user-agent");
const res = await fetch("https://api.shop.example.com/me", {
headers: { Authorization: `Bearer ${sessionToken}` },
cache: "no-store",
});
return <div>こんにちは、{(await res.json()).name}</div>;
}
cookies()またはheaders()を呼び出すと、ルートが自動的に dynamic としてマークされます — これは合理的です。なぜなら、データが特定のユーザーに依存するから。cache: "no-store"と組み合わせることで、データが session に従って常に fresh に保たれることを保証します。
明示的に dynamic を強制します(例えば確実にキャッシュできないと分かっている場合):
export const dynamic = "force-dynamic";
9. Route Handlers もキャッシュを持つ
Route Handler(app/api/.../route.ts)の GET メソッドも、Server Component と同じキャッシング規則に従います:
// app/api/stock/route.ts
export const revalidate = 60;
export async function GET() {
const res = await fetch("https://api.shop.example.com/stock");
const data = await res.json();
return Response.json(data);
}
GETが request object を使用しない(request.urlからクエリを読み込まない)場合、static キャッシュ可能 です。- handler が
request.headers、request.cookiesを使用したり、GET以外のメソッド(POST、PUTなど)の場合 → 常に dynamic であり、キャッシュされません。
10. Client Component:キャッシュは別のライブラリ
Next.js の fetch キャッシュは server でのみ 適用されます。Client Component では、useEffect で自分自身で fetch するか、SWR のような別のキャッシュライブラリを使用します:
npm install swr
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function CartBadge({ userId }: { userId: string }) {
const { data, isLoading } = useSWR(`/api/cart/${userId}`, fetcher, {
refreshInterval: 10_000, // 10 秒ごとにポーリング
});
if (isLoading) return <span>...</span>;
return <span>{data.itemCount} 件</span>;
}
- SWR はキャッシュ、request の重複排除、focus 時の再検証を提供します — server の Data Cache から完全に独立しています。
- client で継続的にデータを更新したいがページ全体をリロードしたくない場合に使用します。
11. キャッシュキーに関連するロジックのテスト記述
Next.js のキャッシング機構そのもので unit test を書くのは難しいですが、tag/path を生成するロジックは十分にテストする価値があります — tag のタイプミスで revalidateTag が一致しないエラーを防ぐため。
// lib/cache-keys.ts
export function productTag(id: string): string {
return `product-${id}`;
}
export function categoryTag(id: string): string {
return `category-${id}`;
}
// lib/cache-keys.test.ts
import { describe, it, expect } from "vitest";
import { productTag, categoryTag } from "./cache-keys";
describe("cache-keys", () => {
it("正しい形式の商品タグを生成する", () => {
expect(productTag("sku-123")).toBe("product-sku-123");
});
it("商品タグとカテゴリタグは重複しない", () => {
const id = "42";
expect(productTag(id)).not.toBe(categoryTag(id));
});
});
- tag 生成関数を別のモジュールに分離すると、
fetchが tag を使用している場所(タグ付け)とrevalidateTagを呼び出している場所(タグ削除)が常に同じ形式を使用することを確保できます。 - これは実際によくあるエラーです:一方が
product-${id}、もう一方がproducts-${id}と書いているため、再検証が完全に一致しません。
✓ cache-keys > 正しい形式の商品タグを生成する
✓ cache-keys > 商品タグとカテゴリタグは重複しない
12. よくあるエラー
- fetch がデフォルトで無期限キャッシュされることを忘れる:デプロイ後もデータが変わらない。以前のビルドからのキャッシュ(static export の場合)またはビルド後の最初の fetch からのキャッシュを使用し続けているため。
revalidateまたはno-storeで修正します。 - 不要に同じコンポーネント内で
no-storeとキャッシュを混在させる:1 つのno-storefetch だけでページ全体が dynamic になり、他の fetch のキャッシュメリットがすべて失われます。 - Server Action/Route Handler 以外の場所で
revalidateTag/revalidatePathを呼び出す:効果がなく、Next.js はバージョンによってエラーを報告するか無視します。 - サードパーティライブラリ内で通常の
fetchを使用(例:axiosまたはundiciを使用する SDK):Data Cache で管理されず、別の方法で再検証する必要があります(別のレイヤでのキャッシュまたは標準fetchを呼び出すラッパーの記述)。 - entity 固有ではなく汎用的にタグ付けする:すべての商品に対して
"products"タグを使用するなど、小さな更新でキャッシュ全体が無効化され、キャッシュのメリットが失われます。
キャッシュすべきでない場合: session に基づいてパーソナライズされたデータ(カート、残高、通知)、リアルタイムデータ(株価、支払い状況)、または
cookies()/headers()に依存する任何の response。これらのケースではno-storeまたはforce-dynamicを最初から使用し、その後継続的に再検証しようとしないようにします — 複雑になるだけでなく、データの本質に合致しません。
13. まとめ
Next.js App Router のキャッシュには複数のレイヤがありますが、最も直接的に制御できるのは Data Cache であり、fetch のオプション:cache、next.revalidate、next.tags を通じて制御します。素早い選択ルール:変わりにくいデータ → 時間ベースの revalidate;特定のイベントで変わるデータ(管理者による更新)→ tags を設定して revalidateTag を使用;ユーザーまたはリアルタイムデータに依存するデータ → no-store/force-dynamic。この 3 つのグループを理解すれば、「なぜデータが更新されないの?」と推測することはほぼなくなるでしょう。