T-CREATOR

Next.js の キャッシュ無効化設計:タグ・パス・スケジュールの 3 軸でコントロール

Next.js の キャッシュ無効化設計:タグ・パス・スケジュールの 3 軸でコントロール

Next.js を使った Web アプリケーション開発では、パフォーマンスと鮮度のバランスが常に課題となります。キャッシュを活用すればページの表示速度は向上しますが、古いデータが表示され続けてしまう問題も発生するでしょう。

本記事では、Next.js が提供する 3 つのキャッシュ無効化手法(タグベース・パスベース・スケジュールベース)を組み合わせた設計方法を解説します。それぞれの特性を理解し、状況に応じて使い分けることで、最適なキャッシュ戦略を構築できますね。

背景

Next.js のキャッシュシステム

Next.js 13 以降の App Router では、複数のレイヤーでキャッシュが機能しています。これらのキャッシュは、アプリケーションのパフォーマンスを劇的に向上させる一方で、適切な管理が必要です。

Next.js が提供する主なキャッシュには以下のものがあります。

#キャッシュ種別対象保存場所有効期限
1Request Memoizationfetch リクエストサーバーメモリリクエスト単位
2Data Cachefetch レスポンスサーバーストレージ永続的(設定可)
3Full Route Cacheレンダリング結果サーバーストレージ永続的(設定可)
4Router Cacheページセグメントクライアントメモリセッション単位

これらのキャッシュが階層的に機能することで、ユーザーは高速なページ表示を体験できます。

キャッシュの仕組みを図解

以下の図は、Next.js のキャッシュがどのように連携して動作するかを示しています。

mermaidflowchart TB
    user["ユーザー"] -->|リクエスト| router["Router Cache<br/>(クライアント)"]
    router -->|キャッシュミス| server["Next.js Server"]
    server -->|確認| fullRoute["Full Route Cache"]
    fullRoute -->|キャッシュミス| render["レンダリング処理"]
    render -->|データ要求| dataCache["Data Cache"]
    dataCache -->|キャッシュミス| fetch["外部 API / DB"]
    fetch -->|レスポンス| dataCache
    dataCache -->|データ| render
    render -->|HTML| fullRoute
    fullRoute -->|レスポンス| router
    router -->|表示| user

図で理解できる要点:

  • クライアントからサーバーまで、複数のキャッシュ層が存在する
  • 各層でキャッシュヒットすれば、以降の処理をスキップできる
  • 最終的に外部 API や DB へのアクセスを最小限に抑える設計となっている

この多層構造により、同じデータへの重複リクエストを削減し、サーバー負荷を大幅に軽減できるのです。

App Router での変更点

Next.js 12 までの Pages Router と比較して、App Router ではキャッシュの挙動が大きく変わりました。

最も重要な変更点は、デフォルトでキャッシュが有効になったことです。Pages Router では getStaticPropsgetServerSideProps で明示的にキャッシュ戦略を指定していましたが、App Router では自動的にキャッシュされます。

課題

キャッシュ管理の複雑さ

多層キャッシュシステムは強力ですが、適切に管理しないと深刻な問題を引き起こします。

代表的な課題を以下に示しました。

#課題影響発生頻度
1古いデータの表示ユーザーが最新情報を見られない★★★
2部分的な更新の難しさ関連ページの同期が取れない★★☆
3キャッシュ戦略の設計ミスパフォーマンス低下や開発コスト増★★★
4デバッグの困難さキャッシュが原因かの判断が難しい★★☆

これらの課題に対処するには、キャッシュの無効化(Revalidation)を適切に設計する必要があります。

具体的な問題シナリオ

実際の開発現場では、以下のような問題に直面することが多いでしょう。

シナリオ 1:ブログ記事の更新 管理画面で記事を更新したのに、フロントエンドでは古い内容が表示され続ける。記事一覧ページと詳細ページで内容が異なる状態になってしまう。

シナリオ 2:在庫情報の不整合 EC サイトで商品の在庫が更新されたのに、商品一覧や詳細ページ、カートページで異なる在庫数が表示され、ユーザーに混乱を与える。

シナリオ 3:ユーザー情報の反映遅延 プロフィールを更新したのに、ヘッダーやサイドバーに表示される名前が古いまま。ページ全体を再読み込みしないと反映されない。

以下の図は、これらの問題がどのように発生するかを示しています。

mermaidsequenceDiagram
    participant Admin as 管理者
    participant CMS as CMS/DB
    participant Cache as キャッシュ
    participant User as ユーザー

    Admin->>CMS: 記事を更新
    CMS->>CMS: データ保存
    Note over Cache: 古いキャッシュが<br/>残っている
    User->>Cache: ページ表示要求
    Cache->>User: 古いデータを返却
    Note over User: 更新が反映<br/>されない!

図で理解できる要点:

  • CMS やデータベースは更新されているのに、キャッシュ層が古いデータを保持している
  • ユーザーのリクエストはキャッシュ層で処理されるため、最新データが取得されない
  • キャッシュの無効化処理が必要

このような問題を解決するために、Next.js は 3 つの軸でキャッシュを無効化する仕組みを提供しています。

解決策

3 軸のキャッシュ無効化戦略

Next.js では、用途に応じて 3 つの異なるキャッシュ無効化手法を使い分けることができます。それぞれの特性を理解し、組み合わせることで、最適なキャッシュ戦略を構築できるでしょう。

以下の表で、3 つの手法を比較してみましょう。

#手法トリガー粒度用途
1タグベース明示的な API 呼び出し細かい(タグ単位)データ更新時の即座の反映
2パスベース明示的な API 呼び出し中程度(パス単位)特定ページの再生成
3スケジュールベース時間経過粗い(ページ全体)定期的なデータ更新

これら 3 つを組み合わせることで、柔軟なキャッシュ戦略を実現できます。

各手法の選択基準

どの手法を選ぶべきかは、データの特性とビジネス要件によって決まります。

タグベースを選ぶべき場合:

  • 複数のページで同じデータを使用している
  • データ更新時に関連する全てのページを一括で更新したい
  • 細かい粒度でキャッシュを管理したい

パスベースを選ぶべき場合:

  • 特定のページやセクションだけを更新したい
  • ページ単位での更新管理がシンプルで十分
  • 更新対象のパスが明確に特定できる

スケジュールベースを選ぶべき場合:

  • データが定期的に更新される
  • リアルタイム性は不要だが、鮮度は保ちたい
  • サーバー負荷を平準化したい

以下の図は、3 つの手法の関係性と使い分けを示しています。

mermaidflowchart TD
    start["キャッシュ無効化が必要"] --> question1{"リアルタイム性<br/>が必要?"}
    question1 -->|はい| question2{"更新範囲は<br/>複数ページ?"}
    question1 -->|いいえ| schedule["スケジュールベース<br/>(revalidate)"]

    question2 -->|はい| tag["タグベース<br/>(revalidateTag)"]
    question2 -->|いいえ| path["パスベース<br/>(revalidatePath)"]

    tag --> result1["関連する全ページを<br/>一括更新"]
    path --> result2["特定ページのみ<br/>更新"]
    schedule --> result3["定期的に自動更新"]

図で理解できる要点:

  • リアルタイム性の有無が最初の判断基準
  • リアルタイムが必要な場合は、更新範囲でタグとパスを使い分ける
  • 定期更新で十分な場合はスケジュールベースが適している

この判断フローに従うことで、適切な無効化手法を選択できますね。

タグベース無効化の仕組み

タグベース無効化は、revalidateTag 関数を使用して実装します。fetch リクエストにタグを付与しておき、そのタグを指定して無効化することで、関連する全てのキャッシュを一括で更新できます。

メリット:

  • 複数のページやコンポーネントで共有されるデータを一括更新できる
  • データの整合性を保ちやすい
  • 柔軟な粒度でキャッシュ管理が可能

デメリット:

  • タグの設計が必要
  • タグの命名規則を統一しないと管理が煩雑になる

パスベース無効化の仕組み

パスベース無効化は、revalidatePath 関数を使用して実装します。指定したパスのキャッシュを無効化し、次回アクセス時に再生成させることができます。

メリット:

  • 直感的で理解しやすい
  • 特定ページの更新がシンプルに実装できる
  • パスの指定だけで動作する

デメリット:

  • 関連ページが多い場合、複数回呼び出す必要がある
  • ページ構造の変更に影響を受けやすい

スケジュールベース無効化の仕組み

スケジュールベース無効化は、revalidate オプションで秒数を指定して実装します。指定した時間が経過すると、自動的にキャッシュが無効化されます。

メリット:

  • 実装が最もシンプル
  • 自動的に定期更新される
  • サーバー負荷が予測しやすい

デメリット:

  • 即座の反映はできない
  • 更新タイミングを細かく制御できない
  • 時間経過までは古いデータが表示される

具体例

タグベース無効化の実装

タグベース無効化は、ブログシステムやニュースサイトなど、複数のページで同じデータを使用する場合に最適です。

まず、データ取得時にタグを付与する実装を見ていきましょう。

データ取得関数の実装

typescript// app/lib/api.ts

/**
 * 記事一覧を取得する関数
 * タグ 'posts' を付与してキャッシュ
 */
export async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: {
      tags: ['posts'], // タグを付与
    },
  });

  if (!res.ok) {
    throw new Error('Failed to fetch posts');
  }

  return res.json();
}

この関数では、fetch リクエストに tags: ['posts'] を指定しています。これにより、このリクエストのキャッシュに posts というタグが付与されます。

特定の記事取得関数

typescript// app/lib/api.ts

/**
 * 特定の記事を取得する関数
 * 記事 ID に応じたタグを付与
 */
export async function getPost(id: string) {
  const res = await fetch(
    `https://api.example.com/posts/${id}`,
    {
      next: {
        tags: ['posts', `post-${id}`], // 複数のタグを付与
      },
    }
  );

  if (!res.ok) {
    throw new Error('Failed to fetch post');
  }

  return res.json();
}

ここでは、posts という汎用タグと、post-${id} という個別タグの両方を付与しています。これにより、全記事を一括更新することも、特定の記事だけを更新することも可能になりますね。

Server Actions での無効化処理

次に、Server Actions を使って、記事更新時にキャッシュを無効化する実装を見ていきます。

typescript// app/actions/posts.ts
'use server';

import { revalidateTag } from 'next/cache';

/**
 * 記事を更新する Server Action
 * 更新後に関連キャッシュを無効化
 */
export async function updatePost(id: string, data: any) {
  // 記事を更新
  const response = await fetch(
    `https://api.example.com/posts/${id}`,
    {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    }
  );

  if (!response.ok) {
    throw new Error('Failed to update post');
  }

  // 特定の記事のキャッシュを無効化
  revalidateTag(`post-${id}`);

  return response.json();
}

この実装では、記事を更新した後に revalidateTag を呼び出しています。これにより、該当する記事のキャッシュが即座に無効化されます。

全記事の一括無効化

管理画面で複数の記事を一括操作する場合は、以下のように実装できます。

typescript// app/actions/posts.ts
'use server';

import { revalidateTag } from 'next/cache';

/**
 * 全記事のキャッシュを無効化
 * カテゴリ変更など、広範囲に影響する更新で使用
 */
export async function refreshAllPosts() {
  // posts タグを持つ全てのキャッシュを無効化
  revalidateTag('posts');

  return { success: true };
}

posts タグを無効化することで、記事一覧ページ、個別記事ページなど、関連する全てのページのキャッシュを一括で更新できます。

コンポーネントでの使用例

実際にコンポーネントから Server Actions を呼び出す例を見てみましょう。

typescript// app/admin/posts/[id]/edit/page.tsx
'use client';

import { updatePost } from '@/app/actions/posts';
import { useState } from 'react';

export default function EditPostPage({
  params,
}: {
  params: { id: string };
}) {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    try {
      // Server Action を呼び出し
      await updatePost(params.id, { title, content });
      alert('記事を更新しました');
    } catch (error) {
      alert('更新に失敗しました');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* フォームの実装 */}
    </form>
  );
}

このように、フォーム送信時に Server Actions を呼び出すだけで、自動的にキャッシュが無効化されます。

パスベース無効化の実装

パスベース無効化は、特定のページやセクションを更新したい場合に適しています。

基本的な無効化処理

typescript// app/actions/pages.ts
'use server';

import { revalidatePath } from 'next/cache';

/**
 * トップページのキャッシュを無効化
 */
export async function refreshHomePage() {
  revalidatePath('/');
  return { success: true };
}

最もシンプルな実装例です。revalidatePath('​/​') を呼び出すことで、トップページのキャッシュが無効化されます。

動的ルートの無効化

次に、動的ルートのキャッシュを無効化する実装を見ていきましょう。

typescript// app/actions/products.ts
'use server';

import { revalidatePath } from 'next/cache';

/**
 * 商品情報を更新し、該当ページを無効化
 */
export async function updateProduct(id: string, data: any) {
  // 商品情報を更新
  await fetch(`https://api.example.com/products/${id}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(data),
  });

  // 商品詳細ページを無効化
  revalidatePath(`/products/${id}`);

  // 商品一覧ページも無効化
  revalidatePath('/products');

  return { success: true };
}

この実装では、商品詳細ページと商品一覧ページの両方を無効化しています。関連するページを複数指定することで、データの整合性を保つことができますね。

レイアウトを含む無効化

レイアウトコンポーネントも含めて無効化したい場合は、type オプションを指定します。

typescript// app/actions/settings.ts
'use server';

import { revalidatePath } from 'next/cache';

/**
 * サイト設定を更新
 * レイアウトを含む全ページを無効化
 */
export async function updateSiteSettings(data: any) {
  // 設定を更新
  await fetch('https://api.example.com/settings', {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(data),
  });

  // レイアウトを含む全ページを無効化
  revalidatePath('/', 'layout');

  return { success: true };
}

revalidatePath('​/​', 'layout') と指定することで、ルートレイアウト配下の全てのページが無効化されます。サイト全体に影響する設定変更時に有効でしょう。

Route Handler での使用例

Route Handler からキャッシュを無効化する実装も見てみましょう。

typescript// app/api/revalidate/route.ts

import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

/**
 * Webhook などから呼び出される無効化エンドポイント
 */
export async function POST(request: NextRequest) {
  // 認証トークンの検証
  const token = request.headers.get('authorization');

  if (token !== `Bearer ${process.env.REVALIDATE_TOKEN}`) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  // リクエストボディからパスを取得
  const { path } = await request.json();

  if (!path) {
    return NextResponse.json(
      { error: 'Path is required' },
      { status: 400 }
    );
  }

  // 指定されたパスを無効化
  revalidatePath(path);

  return NextResponse.json({ revalidated: true, path });
}

この実装では、外部システムから Webhook を受け取り、指定されたパスのキャッシュを無効化できます。CMS や他のサービスとの連携に便利ですね。

スケジュールベース無効化の実装

スケジュールベース無効化は、定期的にデータが更新されるコンテンツに最適です。

ページレベルでの設定

typescript// app/blog/page.tsx

import { getPosts } from '@/app/lib/api';

/**
 * ブログ一覧ページ
 * 60秒ごとにキャッシュを無効化
 */
export const revalidate = 60; // 秒単位で指定

export default async function BlogPage() {
  const posts = await getPosts();

  return (
    <div>
      <h1>ブログ記事一覧</h1>
      <ul>
        {posts.map((post: any) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

export const revalidate = 60 と記述するだけで、60 秒ごとにページが再生成されます。非常にシンプルですね。

fetch レベルでの設定

個別の fetch リクエストに対しても revalidate を設定できます。

typescript// app/lib/api.ts

/**
 * ニュース一覧を取得
 * 30秒ごとにキャッシュを更新
 */
export async function getNews() {
  const res = await fetch('https://api.example.com/news', {
    next: {
      revalidate: 30, // この fetch のみ30秒でキャッシュ無効化
    },
  });

  if (!res.ok) {
    throw new Error('Failed to fetch news');
  }

  return res.json();
}

ページ全体ではなく、特定のデータ取得だけに revalidate を適用したい場合に使用します。

異なる更新頻度の組み合わせ

1 つのページで複数のデータソースがあり、それぞれ異なる更新頻度が必要な場合の実装例です。

typescript// app/dashboard/page.tsx

/**
 * ダッシュボードページ
 * 複数のデータを異なる頻度で更新
 */
export default async function DashboardPage() {
  // 10秒ごとに更新(リアルタイム性が高い)
  const stats = await fetch(
    'https://api.example.com/stats',
    {
      next: { revalidate: 10 },
    }
  ).then((res) => res.json());

  // 300秒(5分)ごとに更新(更新頻度が低い)
  const reports = await fetch(
    'https://api.example.com/reports',
    {
      next: { revalidate: 300 },
    }
  ).then((res) => res.json());

  return (
    <div>
      <h1>ダッシュボード</h1>
      <section>
        <h2>リアルタイム統計</h2>
        <p>アクセス数: {stats.visits}</p>
      </section>
      <section>
        <h2>レポート</h2>
        <p>月次売上: {reports.monthlySales}</p>
      </section>
    </div>
  );
}

データの性質に応じて更新頻度を変えることで、効率的なキャッシュ戦略を実現できます。

3 軸を組み合わせた実装例

実際のプロダクトでは、3 つの手法を組み合わせることで、より柔軟なキャッシュ戦略を構築できます。

以下は、EC サイトを想定した実装例です。

typescript// app/lib/products.ts

/**
 * 商品一覧を取得
 * 基本は5分ごとに更新、明示的な無効化も可能
 */
export async function getProducts() {
  const res = await fetch(
    'https://api.example.com/products',
    {
      next: {
        revalidate: 300, // スケジュールベース:5分
        tags: ['products'], // タグベース:即座に更新も可能
      },
    }
  );

  return res.json();
}
typescript// app/actions/inventory.ts
'use server';

import { revalidateTag, revalidatePath } from 'next/cache';

/**
 * 在庫を更新
 * 関連する全てのキャッシュを無効化
 */
export async function updateInventory(
  productId: string,
  quantity: number
) {
  // 在庫を更新
  await fetch(
    `https://api.example.com/inventory/${productId}`,
    {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ quantity }),
    }
  );

  // タグベースで商品データを無効化
  revalidateTag('products');
  revalidateTag(`product-${productId}`);

  // パスベースで特定ページを無効化
  revalidatePath(`/products/${productId}`);
  revalidatePath('/products');

  return { success: true };
}

この実装では、以下の戦略を組み合わせています:

  1. スケジュールベース:通常時は 5 分ごとに自動更新
  2. タグベース:在庫更新時に関連する全商品データを即座に更新
  3. パスベース:特定の商品ページと一覧ページを確実に更新

このように組み合わせることで、通常時の負荷を抑えつつ、重要な更新は即座に反映できます。

エラーハンドリングの実装

キャッシュ無効化処理でエラーが発生した場合の対処も重要です。

typescript// app/actions/posts.ts
'use server';

import { revalidateTag } from 'next/cache';

/**
 * エラーハンドリング付きの記事更新
 */
export async function updatePostWithErrorHandling(
  id: string,
  data: any
) {
  try {
    // 記事を更新
    const response = await fetch(
      `https://api.example.com/posts/${id}`,
      {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      }
    );

    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    // キャッシュを無効化
    revalidateTag(`post-${id}`);
    revalidateTag('posts');

    return { success: true, data: await response.json() };
  } catch (error) {
    console.error('Error updating post:', error);

    // エラー情報を返却
    return {
      success: false,
      error:
        error instanceof Error
          ? error.message
          : 'Unknown error',
    };
  }
}

エラー時にも適切な情報を返すことで、UI 側で適切なフィードバックを表示できますね。

以下の図は、3 軸を組み合わせた EC サイトのキャッシュ戦略を示しています。

mermaidflowchart LR
    subgraph normal["通常時"]
        schedule["スケジュールベース<br/>5分ごとに自動更新"]
    end

    subgraph urgent["在庫更新時"]
        tag["タグベース<br/>products タグを無効化"]
        path["パスベース<br/>商品ページを無効化"]
    end

    normal -.->|定期更新| cache["キャッシュ"]
    urgent -->|即座に更新| cache
    cache --> user["ユーザー"]

図で理解できる要点:

  • 通常時は定期的な自動更新で負荷を分散
  • 重要な更新(在庫変更など)は即座に反映
  • 2 つの戦略を使い分けることで、最適なバランスを実現

まとめ

Next.js のキャッシュ無効化は、タグ・パス・スケジュールの 3 軸を理解し、適切に組み合わせることが重要です。

それぞれの手法には以下の特徴があります。

タグベース(revalidateTag) 複数のページで共有されるデータを一括更新できる柔軟な手法です。データの整合性を保ちやすく、細かい粒度でのキャッシュ管理が可能になります。

パスベース(revalidatePath) 特定のページやセクションをシンプルに更新できる直感的な手法です。パスを指定するだけで動作するため、理解しやすく実装も簡単でしょう。

スケジュールベース(revalidate) 定期的に自動更新される最もシンプルな手法です。リアルタイム性は不要だが鮮度を保ちたいコンテンツに最適で、サーバー負荷も予測しやすくなります。

実際のプロダクトでは、これら 3 つを組み合わせることで、パフォーマンスとデータの鮮度を両立できます。通常時はスケジュールベースで負荷を分散し、重要な更新時はタグベースやパスベースで即座に反映するという戦略が効果的ですね。

キャッシュ戦略は、アプリケーションの性質やビジネス要件によって異なります。本記事で紹介した実装パターンを参考に、自分のプロジェクトに最適な設計を見つけていただければ幸いです。

関連リンク