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-store fetch だけでページ全体が 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 つのグループを理解すれば、「なぜデータが更新されないの?」と推測することはほぼなくなるでしょう。

nextjsreactcachingapp-routerserver-components