•22 min read

Next.js App Router動的再検証ガイド

Next.js App Router動的再検証ガイド

Next.jsのApp Routerに最近移行した方なら、このフレームワークの最も賛否両論ある機能、つまり積極的なデフォルトキャッシュに遭遇したことでしょう。Next.jsはデータを非常に積極的にキャッシュするため、サイトは世界中でミリ秒未満でロードされますが、更新されたデータを表示させるのは、熊と格闘するような気分になるかもしれません。

App Routerを使い始めた最初の1ヶ月は、古いデータとの戦いに費やしました。ソースコードを深く掘り下げ、いくつかの高トラフィックアプリをデプロイした後、ついに多層キャッシュアーキテクチャを理解しました。

このガイドでは、高レベルな説明は省き、Next.jsのデータキャッシュが実際にどのように機能するか、revalidateTagがオンデマンドで特定のキャッシュエントリをパージする方法、そして古いデータがクライアントルーターキャッシュを悩ませるのを止める方法について掘り下げていきます。また、unstable_cacheを使用したデータベースキャッシングと、堅牢なWebhook再検証ハンドラーを接続する方法についても説明します。

Audio Briefing
0:00 / 0:00

Next.jsデータキャッシュ層を解き明かす

Next.jsのデータキャッシュは、一般的なブラウザキャッシュとは異なります。これはNode.jsサーバー(またはVercelのエッジインフラストラクチャ)上に存在し、HTTPフェッチ応答をリクエスト間、さらにはデプロイメント間で永続的に保存します。サーバーコンポーネントがfetch()呼び出しを発行すると、Next.jsはそれをインターセプトし、データキャッシュをチェックし、ヒットが見つかればネットワークを完全にスキップします。

Next.js App Routerの4層キャッシュアーキテクチャ

キャッシュ管理を習得するには、ソフトウェアエンジニアはNext.js App Router内で動作する4つの異なるキャッシング層を理解する必要があります。

+-----------------------------------------------------------------------------------+
| Next.js App Router Caching Sub-system Architecture                               |
+-----------------------------------------------------------------------------------+
| 1. Request Memoization  | Server request scope  | Deduplicates fetch calls in 1 render |
| 2. Data Cache          | Persistent server storage | Stores fetch responses across requests|
| 3. Full Route Cache    | Server build storage  | Stores HTML & RSC payloads for static |
| 4. Router Cache        | Client browser memory | Stores visited route segments in SPA  |
+-----------------------------------------------------------------------------------+

サーバーコンポーネントのフェッチリクエスト内で時間ベースの再検証とタグ割り当てを構成する方法を見てみましょう。

// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation';

type Product = {
  id: string;
  slug: string;
  name: string;
  price: number;
  description: string;
  inventoryCount: number;
  category: string;
};

async function getProductData(slug: string): Promise<Product | null> {
  const response = await fetch(`https://api.example.com/products/${slug}`, {
    // Assign cache tags and define 1-hour background revalidation interval
    next: {
      tags: [`product:${slug}`, 'products', 'inventory'],
      revalidate: 3600
    }
  });

  if (response.status === 404) {
    return null;
  }

  if (!response.ok) {
    throw new Error('Failed to fetch product data from backend API.');
  }

  return response.json();
}

async function getRelatedProducts(category: string): Promise<Product[]> {
  const response = await fetch(`https://api.example.com/products?category=${category}`, {
    next: {
      tags: [`category:${category}`, 'products'],
      revalidate: 7200
    }
  });

  if (!response.ok) {
    return [];
  }

  return response.json();
}

export default async function ProductDetailPage({
  params
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const product = await getProductData(slug);

  if (!product) {
    notFound();
  }

  const related = await getRelatedProducts(product.category);

  return (
    <article className="product-page">
      <header className="product-header">
        <h1>{product.name}</h1>
        <p className="price">${product.price.toFixed(2)}</p>
        <p className="inventory">Available Stock: {product.inventoryCount}</p>
      </header>

      <section className="description-section">
        <p className="description">{product.description}</p>
      </section>

      <section className="related-section">
        <h2>Related Products in {product.category}</h2>
        <div className="related-grid">
          {related.map((item) => (
            <div key={item.id} className="related-card">
              <h3>{item.name}</h3>
              <p>${item.price.toFixed(2)}</p>
            </div>
          ))}
        </div>
      </section>
    </article>
  );
}

next.tagsがキャッシュされたレスポンスオブジェクトに意味のある文字列を割り当てていることに注目してください。単一のフェッチリクエストに複数のタグを割り当てることができ、特定の製品(product:keyboard-v2)に対するきめ細かい無効化と、製品リスト(products)に対する広範な一括無効化を可能にします。

また、単一のサーバーレンダリングサイクル中にリクエストメモ化がデータキャッシュとどのように連携するかを見てみましょう。

// Both Layout and Page components call getProductData('keyboard-v2')
// Request Memoization ensures ONLY ONE HTTP request is executed on the server!
export async function HeaderLayout({ children }: { children: React.ReactNode }) {
  const product = await getProductData('keyboard-v2');
  return (
    <div>
      <nav>Current Item: {product?.name}</nav>
      {children}
    </div>
  );
}

HTTP fetchを使用せずにPrismaやDrizzle ORMを使ってデータベースを直接クエリする場合、Next.jsはunstable_cacheユーティリティを提供し、同じタグキャッシング動作を実現します。

// lib/cachedQueries.ts
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';

export const getCachedProductBySlug = (slug: string) =>
  unstable_cache(
    async () => {
      return db.product.findUnique({
        where: { slug }
      });
    },
    [`product-query-${slug}`], // Cache key array
    {
      tags: [`product:${slug}`, 'products'],
      revalidate: 3600
    }
  )();

データベースクエリをunstable_cacheでラップすることで、ORM呼び出しは標準のHTTPフェッチリクエストが享受するのとまったく同じタグパージ機能を利用できます。タグ無効化の恩恵を受けるために、既存のデータベース抽象化レイヤーを書き直す必要はありません。

Advertisement

revalidateTag はどのようにしてきめ細かいオンデマンドキャッシュパージを提供するのか?

revalidateTag 関数は、特定のタグ識別子に割り当てられたすべてのキャッシュされたフェッチエントリをサーバーメモリから即座に無効にすることで、きめ細かいキャッシュパージを提供します。時間ベースの再検証タイマーが期限切れになるのを待つ代わりに、revalidateTag('tag-name')を呼び出すと、データキャッシュから一致するアイテムがパージされ、同時にフルルートキャッシュ内の依存エントリが無効になります。

revalidateTag キャッシュ無効化フロー

この機能は、ヘッドレスCMSワークフローや、価格更新やコンテンツ編集がグローバルルート全体に即座に公開される必要があるeコマースプラットフォームを構築する際に不可欠です。

在庫変更後の製品キャッシュタグをサーバーアクションがパージする方法を見てみましょう。

// actions/inventoryActions.ts
'use server';

import { revalidateTag } from 'next/cache';
import { db } from '@/lib/database';

export async function updateProductInventory(
  productId: string,
  productSlug: string,
  newStockCount: number
) {
  try {
    await db.product.update({
      where: { id: productId },
      data: { inventoryCount: newStockCount }
    });

    // Instantly purge specific product tag and global inventory tag
    revalidateTag(`product:${productSlug}`);
    revalidateTag('inventory');

    return { success: true, message: 'Inventory updated and cache purged successfully!' };
  } catch (error) {
    return { success: false, message: 'Database write failed during inventory update.' };
  }
}

次に、外部のWebhookルートハンドラーが、ContentfulやSanityのようなヘッドレスCMSからの受信コンテンツ更新イベントをどのように処理するかを見てみましょう。

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-webhook-secret');

  if (secret !== process.env.CMS_WEBHOOK_SECRET) {
    return NextResponse.json({ message: 'Unauthorized webhook request.' }, { status: 401 });
  }

  try {
    const body = await request.json();
    const { entityType, slug, tags } = body;

    if (tags && Array.isArray(tags)) {
      // Purge array of incoming webhook tags
      tags.forEach((tag: string) => revalidateTag(tag));
    } else if (slug) {
      revalidateTag(`${entityType}:${slug}`);
    } else {
      revalidateTag(entityType);
    }

    return NextResponse.json({
      revalidated: true,
      timestamp: new Date().toISOString()
    });
  } catch (err) {
    return NextResponse.json(
      { message: 'Error processing webhook revalidation payload.' },
      { status: 500 }
    );
  }
}

キャッシュ無効化を専用のWebhookエンドポイントに分離することで、バックエンドCMSは、エディターが変更を公開するたびに、静的ページルートを再構築することなく、即座にサイトを更新できます。単純なコピー更新のために、サイト全体の再デプロイをトリガーする必要はありません。

さらに、revalidateTagは、受信API応答をブロックすることなく非同期に実行されます。サーバーは、メモリ内の一致するキャッシュキーをパージし、依存する静的ページのバックグラウンド再レンダリングをキューに入れます。

ローカル開発中にタグ無効化をデバッグする際は、ターミナルコンソールで[Cache] Tag "product:123" invalidatedステータスメッセージを確認してください。Next.jsは、デバッグフラグを有効にして開発サーバーを実行しているときに、詳細なキャッシュ操作を出力します。変更を本番環境にプッシュする前に、タグ無効化が期待されるキャッシュキーをパージしていることを確認できます。

エンジニアは動的ルートとクライアントルーターキャッシュの古いデータをどのように防ぐことができるか?

エンジニアは、サーバーの再検証タグとルーターのリフレッシュ呼び出しを組み合わせることで、クライアント側のインメモリールーターキャッシュをパージし、古いデータを防ぎます。クライアントルーターキャッシュは、訪問したReactサーバーコンポーネント(RSC)ペイロードセグメントをブラウザメモリに保存します。revalidateTagがサーバーデータキャッシュを無効にしても、ユーザーが最近訪問したクライアントルートに戻ると、クライアントキャッシュが期限切れになるまで、ブラウザメモリから古いデータが表示される可能性があります。

Stale-While-Revalidateとルーターリフレッシュメカニズム

クライアント側のキャッシュ遅延を解決するには、フロントエンド開発者は、クライアント側の状態変更後にrouter.refresh()からnext/navigationを呼び出す必要があります。router.refresh()を呼び出すと、ブラウザはキャッシュされたRSCペイロードを破棄し、サーバーから新しいレンダリングされたHTMLをフェッチするように強制されます。

サーバーアクションの実行とrouter.refresh()を組み合わせたクライアントコンポーネントの例を示します。

// components/InventoryUpdater.tsx
'use client';

import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { updateProductInventory } from '@/actions/inventoryActions';

type InventoryUpdaterProps = {
  productId: string;
  productSlug: string;
  initialStock: number;
};

export function InventoryUpdater({
  productId,
  productSlug,
  initialStock
}: InventoryUpdaterProps) {
  const router = useRouter();
  const [stock, setStock] = useState(initialStock);
  const [isPending, startTransition] = useTransition();

  const handleStockUpdate = async (newCount: number) => {
    setStock(newCount);

    startTransition(async () => {
      const result = await updateProductInventory(productId, productSlug, newCount);

      if (result.success) {
        // Force the client browser router cache to refresh fresh server RSC payload
        router.refresh();
      } else {
        // Rollback stock state on server write failure
        setStock(initialStock);
        alert(result.message);
      }
    });
  };

  return (
    <div className="stock-controls">
      <span>Current Inventory: {stock}</span>
      <button
        onClick={() => handleStockUpdate(stock + 1)}
        disabled={isPending}
      >
        + Add Stock
      </button>
      <button
        onClick={() => handleStockUpdate(Math.max(0, stock - 1))}
        disabled={isPending || stock === 0}
      >
        - Decrease Stock
      </button>
    </div>
  );
}

さらに、動的ルートセグメントがライブリクエストヘッダーまたはCookieに依存している場合、ルートセグメントパラメータを定義することで、セグメントレベルで動的レンダリングを強制します。

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
export const revalidate = 0;

export default async function LiveDashboardPage() {
  // Page skips static route caching entirely and renders dynamically on every request
  return <div>Live Enterprise Analytics Stream</div>;
}

export const dynamic = 'force-dynamic'を構成することで、ユーザーはページアクセスごとにライブデータを表示することが保証され、トランザクションユーザーダッシュボードのキャッシングリスクが排除されます。マーケティングページには静的キャッシングを、ユーザーダッシュボードには動的レンダリングを組み合わせることで、理想的なアーキテクチャバランスが生まれることがわかるでしょう。ソフトウェアチームは、驚異的な速度と完全なデータの鮮度の両方を実現します。

また、stale-while-revalidateのバックグラウンドフェッチ機能についても見てみましょう。タイマーが期限切れになった後、再検証中のページにリクエストが到達すると、Next.jsはすぐにキャッシュされた古い応答を提供し、同時に新しいデータをフェッチするためのバックグラウンドワーカーを起動します。バックグラウンドフェッチが解決されると、後続のリクエストは自動的に更新されたコンテンツを受け取ります。フレームワークが非同期の再検証タスクをすぐに管理するため、カスタムのバックグラウンドキューワーカーを記述する必要はありません。

エンタープライズキャッシュ無効化アーキテクチャのベストプラクティスとは?

エンタープライズキャッシュ無効化のベストプラクティスには、一貫したタグ命名規則の確立、CMS Webhookとルートハンドラーの分離、キャッシュヒット率の監査が含まれます。複数の自律的なエンジニアリングチームを持つ大規模なエンタープライズ組織では、構造化されていないタグ命名は、すぐにタグの衝突バグやキャッシュパージの漏れにつながります。

エンタープライズNext.jsキャッシングの重要なアーキテクチャガイドラインを確認しましょう。

  1. 階層的なタグ命名標準を採用する: domain:entity:id(例: store:product:9842)のような構造化された名前空間パターンを使用します。階層的な命名により、タグの意図が明確になり、マイクロサービス間での偶発的な衝突を防ぎます。

  2. エンティティタグとリストタグを区別する: 個々のレコード(user:42)と集約されたコレクション(users:all)には、別々のタグを保持します。単一のユーザーアイテムをパージしても、キャッシュされたすべてのユーザーディレクトリリストを不必要にパージすべきではありません。

  3. Webhook再検証トリガーをログに記録する: DatadogやSentryのようなサーバーAPM監視ツール内で、受信Webhookペイロードと実行タイムスタンプをログに記録します。Webhook署名が失敗した場合、不足している更新を迅速に追跡できます。

  4. 分散デプロイメント用のカスタムRedisキャッシュアダプターを実装する: 複数のコンテナインスタンスにNext.jsをデプロイする場合、@nexus/cacheを使用してカスタムRedisキャッシュストレージハンドラーを構成し、すべてのサーバーノード間でデータキャッシュの状態を同期します。

  5. 本番環境でのキャッシュヒット率を監査する: サーバーログでキャッシュHITとMISSヘッダーを監視します。キャッシュミス率が高い場合は、再検証間隔が過度に積極的であるか、タグの関連付けが不足していることを示します。

カスタムNext.jsミドルウェアがダウンストリームAPI応答にカスタムキャッシュヘッダーを追加する方法は次のとおりです。

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  if (request.nextUrl.pathname.startsWith('/api/public')) {
    // Enforce Edge CDN caching headers for public API endpoints
    response.headers.set(
      'Cache-Control',
      'public, s-maxage=3600, stale-while-revalidate=86400'
    );
  }

  return response;
}

Next.jsミドルウェア内でカスタムエッジキャッシングヘッダーを構成することで、大量のパブリックエンドポイントがCDNエッジロケーションから直接キャッシュされた応答を提供できるようになります。高トラフィックのマーケティングキャンペーン中に、バックエンドサーバーのコンピューティングコストを90%以上削減できます。

以下は、revalidatePathとrevalidateTagのトレードオフを強調したアーキテクチャ比較です。

+--------------------------------+-----------------------------------+-----------------------------------+
| Feature Aspect                 | revalidatePath('/products/[slug]')| revalidateTag('product:123')      |
+--------------------------------+-----------------------------------+-----------------------------------+
| Invalidation Scope             | Entire page route URL path        | Specific tagged fetch calls       |
| Granularity Level              | Coarse (Purges full page tree)    | Fine (Purges single fetch entry)  |
| Cross-Route Invalidation       | Limited to matching path pattern  | Global across all route pages     |
| CMS Webhook Integration        | Requires mapping paths manually   | Direct mapping to entity IDs      |
+--------------------------------+-----------------------------------+-----------------------------------+

エンティティレベルの更新にはrevalidateTagを、レイアウトの変更にはrevalidatePathを選択することで、フロントエンドアーキテクトはパフォーマンスとデータの鮮度を正確に制御できます。

また、CDNエッジキャッシングヘッダーがNext.js App Routerの出力とどのように連携するかを見てみましょう。

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=0, must-revalidate
x-nextjs-cache: HIT

VercelやCloudflareのようなエッジプロバイダーにデプロイする場合、Next.jsは舞台裏でstale-while-revalidateキャッシングヘッダーを処理し、CDNノードがキャッシュされたHTMLページを提供しながら、バックグラウンドの再検証ワーカーがサーバーデータキャッシュを非同期に更新するようにします。安定したトラフィック負荷の下でCDNキャッシュヒット率が98%を超えることを確認しており、オリジンデータベースクラスターを突然のトラフィックスパイクから保護しています。

さらに、PlaywrightまたはCypressで記述された自動E2Eテストは、Webhookペイロードをステージング環境に投稿し、更新されたHTMLコンテンツが手動でブラウザを再読み込みすることなく正しくレンダリングされることをアサートすることで、キャッシュ再検証の動作を検証できます。自動キャッシュテストを確立することで、主要なフレームワークのアップグレード中に偶発的な無効化の回帰を防ぐことができます。

Zustand vs Jotai State Management Comparison](/en/blog/zustand-vs-jotai-react-state-management)

Advertisement

こちらもおすすめ

Next.js App Routerの再検証とタグキャッシングに関するよくある質問

クライアントコンポーネント内で revalidateTag を直接呼び出すことはできますか?

いいえ、revalidateTagはサーバー専用の関数であり、クライアントコンポーネント内で直接呼び出すことはできません。revalidateTagはサーバーアクションまたはルートハンドラーエンドポイント内で呼び出す必要があります。

1つのフェッチリクエストに割り当てられるタグの最大数はいくつですか?

Next.jsは、フェッチリクエストごとに割り当てられるタグの数に厳密な制限を設けていません。ただし、フェッチ呼び出しごとに2〜5個のターゲットタグを割り当てることで、キャッシュメタデータを軽量で保守しやすい状態に保つことができます。

revalidateTag はリクエスト実行中にキャッシュエントリを同期的にパージしますか?

はい、revalidateTagを呼び出すと、サーバーデータキャッシュ内の対応するアイテムが同期的に古いものとしてマークされ、次の受信リクエストがオリジンサーバーから新しいデータをフェッチすることが保証されます。

Next.jsの revalidateTag と revalidatePath の違いは何ですか?

revalidatePathは、特定のURLルートパスに関連付けられたすべてのキャッシュされたコンポーネントとフェッチリクエストを無効にするのに対し、revalidateTagは、どのページルートがそれらを消費したかに関係なく、特定のタグ付けされたフェッチリクエストを無効にします。

ローカル開発環境でキャッシュ無効化の欠落をデバッグするにはどうすればよいですか?

ローカルでキャッシュ無効化をデバッグするには、next.config.mjsのlogging.fetches.fullUrl: trueで詳細ログを有効にし、ページレンダリング中のサーバーコンソール出力を検査します。

revalidateTag を呼び出した後もページに古いデータが表示されるのはなぜですか?

古いデータが残っている場合は、クライアントコンポーネントがブラウザのルーターキャッシュをクリアするためにrouter.refresh()呼び出しを必要としているかどうか、またはバックエンドAPIがキャッシュされたHTTP応答ヘッダーを返しているかどうかを確認してください。

PrismaやDrizzleのようなORMクエリで revalidateTag を使用できますか?

はい、データベースクエリをReactのunstable_cacheヘルパー関数でラップできます。この関数は、標準のfetchオプションと同じタグ配列と再検証間隔を受け入れます。

マルチテナントデータベース移行中にキャッシュ無効化をどのように処理しますか?

データベーススキーマ移行を実行する際は、revalidateTag('*')を使用してグローバルキャッシュパージをトリガーするか、サーバーインスタンスを再起動してインメモリデータキャッシュエントリを安全にクリアします。

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement