Next.js revalidateを使った業務実装パターン|ISR活用による効率的なキャッシュ戦略

React / Next.js

Next.js revalidateを使った業務実装パターン|実務で使える5つの戦略

Next.jsの開発を進める中で、キャッシュ戦略は非常に重要な要素です。特にrevalidateは、静的生成(SSG)と動的コンテンツのバランスを取るための強力なツールです。本記事では、実際の業務で遭遇するパターンと、それに対応する実装方法を具体的に解説します。

revalidateの基本解説

Next.jsのrevalidateは、Incremental Static Regeneration(ISR)を実現するキー機能です。静的に生成されたページを、一定期間経過後に自動的に再生成する仕組みです。

基本的な使い方は以下の通りです:

// app/blog/[id]/page.tsx
export const revalidate = 60; // 60秒ごとに再生成

export default function BlogPost({ params }: { params: { id: string } }) {
  return <h1>ブログ記事</h1>;
}

revalidateに秒数を指定することで、その間隔でページが自動的に更新されます。revalidate = falseとすれば永遠にキャッシュされ、revalidate = 0は毎回動的生成となります。

業務で実際に遭遇するユースケース

パターン1:商品情報の更新が頻繁だが、毎回の動的生成は避けたい

ECサイトの商品ページでよくあるケースです。在庫数や価格は変動しますが、毎リクエストでデータベースを叩きたくない場合、ISRが活躍します。

パターン2:ニュースサイトで最新記事は常に更新、過去記事はキャッシュ

フロントページは頻繁に更新が必要ですが、個別記事ページはそこまで頻繁に更新されません。このような差別化が重要です。

パターン3:管理画面から記事を公開したら即座に反映させたい

手動でのリバリデーション(On-Demand Revalidation)が必要になる実務的なシーンです。

パターン4:複数のデータソースの更新タイミングが異なる

ブログ本体の更新は1時間ごと、コメント機能は5分ごと更新したいなど、細かい制御が必要な場合があります。

実装コード:実務で使える4つのパターン

パターン1:固定的なISRの設定

// app/products/[id]/page.tsx
import { getProduct } from '@/lib/db';

export const revalidate = 300; // 5分ごとに再生成

interface Props {
  params: { id: string };
}

export async function generateStaticParams() {
  // ビルド時に事前生成するページのパラメータ
  const products = await getProduct('list');
  return products.map((product) => ({
    id: product.id.toString(),
  }));
}

export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);

  if (!product) {
    return <div>商品が見つかりません</div>;
  }

  return (
    <div>
      <h1>{product.name}</h1>
      <p>価格: ¥{product.price}</p>
      <p>在庫: {product.stock}</p>
      <p>最終更新: {new Date().toLocaleString('ja-JP')}</p>
    </div>
  );
}

このパターンは、生成されたページが5分ごとに自動更新されます。ビルド時に主要な商品ページのみ事前生成し、アクセスされた新しい商品ページはオンデマンドで生成されます。

パターン2:On-Demand Revalidationの実装

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

// シークレットトークンで認証
const REVALIDATE_SECRET = process.env.REVALIDATE_SECRET;

export async function POST(request: NextRequest) {
  const secret = request.headers.get('X-Revalidate-Secret');

  if (secret !== REVALIDATE_SECRET) {
    return NextResponse.json(
      { error: '認証に失敗しました' },
      { status: 401 }
    );
  }

  try {
    const body = await request.json();
    const { type, id, tag } = body;

    // パターンA: 特定のページパスをリバリデート
    if (type === 'path') {
      revalidatePath(`/products/${id}`);
      return NextResponse.json({
        revalidated: true,
        message: `/products/${id} をリバリデートしました`,
      });
    }

    // パターンB: タグベースのリバリデート
    if (type === 'tag') {
      revalidateTag(tag);
      return NextResponse.json({
        revalidated: true,
        message: `${tag} タグをリバリデートしました`,
      });
    }

    // パターンC: ワイルドカードでの一括リバリデート
    if (type === 'wildcard') {
      revalidatePath('/products/[id]', 'page');
      return NextResponse.json({
        revalidated: true,
        message: '全商品ページをリバリデートしました',
      });
    }

    return NextResponse.json(
      { error: 'リバリデートタイプが無効です' },
      { status: 400 }
    );
  } catch (error) {
    return NextResponse.json(
      { error: 'リバリデート処理でエラーが発生しました' },
      { status: 500 }
    );
  }
}

このAPIエンドポイントは、CMS側から商品更新時に呼び出され、キャッシュを即座に更新します。実務では、このエンドポイントをCMSのWebhook機能に連動させることが一般的です。

パターン3:タグベースのキャッシュ管理(複数データソース対応)

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

export async function getProductWithComments(productId: string) {
  // 商品データはproductタグでキャッシュ
  const product = await unstable_cache(
    async () => {
      const res = await fetch(`https://api.example.com/products/${productId}`);
      return res.json();
    },
    [`product-${productId}`],
    { tags: ['products'], revalidate: 3600 } // 1時間
  )();

  // コメントデータはcommentタグでキャッシュ
  const comments = await unstable_cache(
    async () => {
      const res = await fetch(
        `https://api.example.com/products/${productId}/comments`
      );
      return res.json();
    },
    [`comments-${productId}`],
    { tags: ['comments'], revalidate: 300 } // 5分
  )();

  return { product, comments };
}

このパターンでは、同じページ内で異なる更新頻度のデータを管理できます。タグを分けることで、コメント更新時はrevalidateTag('comments')のみを呼び出し、余計なリバリデートを避けることができます。

パターン4:環境に応じた動的な設定

// app/blog/page.tsx
const REVALIDATE_INTERVALS = {
  production: 3600, // 本番環境:1時間
  staging: 600, // ステージング環境:10分
  development: 0, // 開発環境:毎回動的生成
};

export const revalidate =
  REVALIDATE_INTERVALS[
    (process.env.NEXT_PUBLIC_ENV as keyof typeof REVALIDATE_INTERVALS) ||
      'production'
  ];

interface BlogPost {
  id: string;
  title: string;
  slug: string;
  publishedAt: string;
}

async function getAllBlogPosts(): Promise<BlogPost[]> {
  const res = await fetch('https://api.example.com/blog', {
    headers: {
      'Authorization': `Bearer ${process.env.API_KEY}`,
    },
  });

  if (!res.ok) {
    throw new Error('ブログ記事の取得に失敗しました');
  }

  return res.json();
}

export default async function BlogListPage() {
  const posts = await getAllBlogPosts();

  return (
    <main>
      <h1>ブログ一覧</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/blog/${post.slug}`}>
              {post.title}
            </a>
            <time>{new Date(post.publishedAt).toLocaleDateString('ja-JP')}</time>
          </li>
        ))}
      </ul>
    </main>
  );
}

本番環境、ステージング環境、開発環境で異なるキャッシュ戦略を適用できます。これにより、開発時は毎回最新データで作業でき、本番環境ではサーバー負荷を軽減できます。

よくある応用パターン

応用1:データベース側でのタイムスタンプ管理

// lib/cache-utils.ts
import { revalidatePath } from 'next/cache';

/**
 * 最後の更新時刻に基づいて自動的にrevalidateを計算
 * 最新の更新から30秒以内なら頻繁に再生成、それ以上なら時間を長くする
 */
export function calculateRevalidateInterval(
  lastUpdatedAt: Date
): number {
  const now = new Date();
  const diffSeconds = (now.getTime() - lastUpdatedAt.getTime()) / 1000;

  if (diffSeconds < 30) {
    return 10; // 最近更新されたなら10秒ごと
  } else if (diffSeconds < 3600) {
    return 300; // 1時間以内なら5分ごと
  } else {
    return 3600; // それ以上なら1時間ごと
  }
}

export async function getProductWithSmartCache(productId: string) {
  const product = await fetch(
    `https://api.example.com/products/${productId}`
  ).then((res) => res.json());

  // この値を返してページコンポーネントで使用
  const revalidateInterval = calculateRevalidateInterval(
    new Date(product.updatedAt)
  );

  return { product, revalidateInterval };
}

応用2:複数ページの一括リバリデーション

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

export async function POST(request: NextRequest) {
  const secret = request.headers.get('X-Revalidate-Secret');

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: '認証失敗' }, { status: 401 });
  }

  try {
    const { paths, tags } = await request.json();

    // 複数パスの一括リバリデート
    if (paths && Array.isArray(paths)) {
      paths.forEach((path) => revalidatePath(path));
    }

    // 複数タグの一括リバリデート
    if (tags && Array.isArray(tags)) {
      tags.forEach((tag) => revalidateTag(tag));
    }

    return NextResponse.json({
      revalidated: true,
      pathCount: paths?.length || 0,
      tagCount: tags?.length || 0,
    });
  } catch (error) {
    return NextResponse.json({ error: '処理エラー' }, { status: 500 });
  }
}

応用3:CMSのWebhook連携実装

// app/api/webhook/cms/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.CMS_WEBHOOK_SECRET || '';

function verifyWebhookSignature(
  payload: string,
  signature: string
): boolean {
  const hash = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');
  return hash === signature;
}

export async function POST(request: NextRequest) {
  const signature = request.headers.get('X-CMS-Signature') || '';
  const body = await request.text();

  // Webhook署名の検証
  if (!verifyWebhookSignature(body, signature)) {
    return NextResponse.json(
      { error: '署名検証失敗' },
      { status: 401 }
    );
  }

  const payload = JSON.parse(body);
  const { event, entityType, entityId } = payload;

  try {
    switch (event) {
      case 'publish': {
        // 記事公開時
        if (entityType === 'blog-post') {
          revalidatePath('/blog');
          revalidatePath(`/blog/${entityId}`);
          revalidateTag('blog-posts');
        }
        // 商品更新時
        if (entityType === 'product') {
          revalidatePath('/products');
          revalidatePath(`/products/${entityId}`);
          revalidateTag(`product-${entityId}`);
        }
        break;
      }
      case 'unpublish': {
        // 記事削除時
        if (entityType === 'blog-post') {
          revalidatePath('/blog');
          revalidateTag('blog-posts');
        }
        break;
      }
      case 'update': {
        // 更新時
        revalidatePath(`/${entityType}/${entityId}`);
        break;
      }
    }

    // 監査ログ
    console.log(`[Webhook] ${event} on ${entityType}:${entityId}`);

    return NextResponse.json({
      success: true,
      message: 'リバリデート完了',
    });
  } catch (error) {
    console.error('[Webhook Error]', error);
    return NextResponse.json(
      { error: '処理エラー' },
      { status: 500 }
    );
  }
}

注意点と落とし穴

注意1:revalidateは秒単位だが、最小単位がある

Next.jsのISRでは、revalidateの最小単位は60秒です。1秒や5秒に設定しても、実際には60秒ごとに再生成されます。より頻繁な更新が必要な場合は、動的生成(revalidate = 0)やリアルタイムデータベースの利用を検討してください。

注意2:generateStaticParamsは完全性が必須

未登録のパラメータにアクセスされた場合、オンデマンド生成されます。パラメータが多い場合はビルド時間が長くなるため、メインのページのみ事前生成し、その他はオンデマンド生成とするのが実務的です。

注意3:On-Demand Revalidationは非同期

リバリデーション処理は非同期で実行されるため、APIレスポンスの直後には反映されていない可能性があります。ユーザーへの通知は慎重に行いましょう。

注意4:キャッシュの重複排除に気をつける

複数のページで同じデータ取得関数を使う場合、Next.jsは自動的にキャッシュを共有します。異なる更新頻度が必要な場合は明示的にタグを分けましょう。

注意5:本番環境でのデバッグ

キャッシュの動作確認は、ローカル環境(npm run dev)では正常に動作しても、本番環境(next build && next start)で異なる場合があります。必ずビルド後のプロダクションモードで検証してください。

# 本番環境でのテスト
npm run build
npm run start

# これでアクセスしてキャッシュ動作を確認

注意6:エラー時のキャッシュ戦略

// 不正なキャッシュを避けるための実装
export default async function SafePage({ params }: Props) {
  try {
    const data = await fetchData(params.id);

    if (!data) {
      // 404の場合はキャッシュさせない
      return new Response('Not found', { status: 404 });
    }

    return <div>{data.content}</div>;
  } catch (error) {
    // エラー発生時も短いrevalidateを設定
    // またはキャッシュを回避して毎回生成させる
    console.error(error);
    throw error; // ISRでエラーページの生成を試行
  }
}

まとめ

Next.jsのrevalidateは、静的な高速性と動的な新鮮性のバランスを取るための重要な機能です。本記事で紹介した4つの実装パターンと応用例を理解することで、以下のような業務シーンに対応できるようになります:

  • 固定ISR:更新頻度が一定の商品やブログ記事
  • On-Demand Revalidation:管理画面から即座に反映させたい情報
  • タグベースキャッシュ:複数データソースの細かい制御
  • 環境別設定:開発・ステージング・本番環境での最適化
  • CMS連携:自動化されたワークフロー

実務開発では、単純なISRだけでなく、環境に応じた柔軟なキャッシュ戦略が求められます。ここで紹介したコード例を参考に、プロジェクトの特性に合わせてカスタマイズして活用してください。また、キャッシュ戦略は性能とコスト、ユーザー体験のトレードオフです。定期的なモニタリングと見直しを習慣にすることで、より効果的なサイト運用が実現できます。

タイトルとURLをコピーしました