Next.js ISR(Incremental Static Regeneration)を業務で活用する実装パターン集

React / Next.js

Next.js ISR(Incremental Static Regeneration)を業務で活用する実装パターン集

2024年現在、Next.jsでのWebアプリケーション開発において、ISR(Incremental Static Regeneration)は非常に重要な機能になっています。本記事では、教科書的な解説ではなく、実際の業務で使えるISRの実装パターンを中心に解説します。

ISRの簡易的な解説

ISRはNext.jsの静的生成(SSG)と動的生成(SSR)のハイブリッドアプローチです。ビルド時に静的ページを生成しつつ、指定した時間経過後に自動的にページを再生成できる機能です。

従来のSSGではビルド後のコンテンツ更新に時間がかかりましたが、ISRを使うと以下が実現できます:

  • 初回アクセスは高速な静的コンテンツを配信
  • バックグラウンドで自動的にコンテンツを再生成
  • キャッシュを戦略的に無効化できる
  • オンデマンドで再生成をトリガーできる

業務でのユースケース

ユースケース1:ECサイトの商品一覧ページ

実務では、数千〜数万の商品を持つECサイトの商品一覧・詳細ページをISRで管理することがよくあります。商品情報は定期的に更新されますが、全ページを常時動的生成すると負荷が高くなります。ISRなら以下のように対応できます:

  • 各商品ページは1時間ごとに自動再生成
  • 在庫が変わった時のみオンデマンド再生成
  • 新商品は手動でリバリデーション

ユースケース2:ブログ・ニュースサイト

更新頻度が中程度の記事コンテンツは、ISRの典型的なユースケースです。アクセス数の多い記事ほどキャッシュを長めに設定し、マイナーな記事は短めに設定できます。

ユースケース3:データベース連携ページ

APIやデータベースから取得したデータを表示するページでは、ISRにより以下が実現できます:

  • ページロード時間の短縮
  • データベース負荷の軽減
  • キャッシュ戦略による柔軟な更新

実装コード:基本的なISRの設定

基本形:getStaticPropsでのrevalidate設定

// pages/products/[id].tsx
import { GetStaticProps, GetStaticPaths } from 'next';

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  updatedAt: string;
}

interface Props {
  product: Product;
  generatedAt: string;
}

export default function ProductPage({ product, generatedAt }: Props) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>価格: ¥{product.price.toLocaleString()}</p>
      <p>{product.description}</p>
      <p style={{ fontSize: '0.8em', color: '#999' }}>
        このページは{new Date(generatedAt).toLocaleString('ja-JP')}に生成されました
      </p>
    </div>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  // ビルド時に生成する商品IDの一覧を取得
  // 全商品を事前生成するのではなく、アクセスの多そうなものだけ
  const topProducts = await fetch('https://api.example.com/products?popular=true')
    .then(res => res.json());

  const paths = topProducts.map((product: Product) => ({
    params: { id: product.id },
  }));

  return {
    paths,
    fallback: 'blocking', // 事前生成されていないIDへのアクセスはSSR
  };
};

export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
  try {
    const id = params?.id as string;
    
    const response = await fetch(`https://api.example.com/products/${id}`);
    if (!response.ok) {
      return {
        notFound: true,
        revalidate: 60, // 404も1分キャッシュ
      };
    }

    const product: Product = await response.json();

    return {
      props: {
        product,
        generatedAt: new Date().toISOString(),
      },
      revalidate: 3600, // 1時間ごとに再生成
    };
  } catch (error) {
    console.error('商品取得エラー:', error);
    return {
      notFound: true,
      revalidate: 300, // エラー時は5分で再試行
    };
  }
};

業務パターン1:オンデマンドISR(手動トリガー)

商品情報が更新された際に、特定のページだけを再生成する必要があります。Next.jsのオンデマンドISR機能を使います。

// pages/api/revalidate.ts
import { NextApiRequest, NextApiResponse } from 'next';

interface RevalidateRequest {
  secret: string;
  productId?: string;
  paths?: string[];
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // シークレットトークンで認可チェック
  if (req.query.secret !== process.env.REVALIDATE_SECRET) {
    return res.status(401).json({ message: '無効なトークン' });
  }

  try {
    const { productId, paths } = req.body as RevalidateRequest;
    const pathsToRevalidate: string[] = [];

    // 単一の商品IDが指定された場合
    if (productId) {
      pathsToRevalidate.push(`/products/${productId}`);
    }

    // 複数パスが指定された場合
    if (paths && Array.isArray(paths)) {
      pathsToRevalidate.push(...paths);
    }

    // 指定されたパスをリバリデーション
    for (const path of pathsToRevalidate) {
      await res.revalidate(path);
    }

    return res.json({
      revalidated: true,
      paths: pathsToRevalidate,
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    console.error('リバリデーションエラー:', error);
    return res.status(500).json({
      message: 'リバリデーション失敗',
      error: error instanceof Error ? error.message : '不明なエラー',
    });
  }
}

リバリデーションAPIの呼び出し例

// 商品更新時の処理
async function updateProduct(productId: string, data: any) {
  // 商品情報をAPIで更新
  const updateResponse = await fetch(`https://api.example.com/products/${productId}`, {
    method: 'PUT',
    body: JSON.stringify(data),
  });

  if (updateResponse.ok) {
    // 商品更新後、ISRをトリガー
    const revalidateResponse = await fetch(
      `${process.env.NEXT_PUBLIC_BASE_URL}/api/revalidate`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          secret: process.env.REVALIDATE_SECRET,
          productId: productId,
        }),
      }
    );

    if (revalidateResponse.ok) {
      console.log('ページリバリデーション完了');
    }
  }
}

業務パターン2:条件付きISR(キャッシュ戦略)

異なるページタイプによって、キャッシュ有効期限を変えるパターンです。アクセス数やコンテンツの更新頻度に応じて、revalidate時間を動的に決定します。

// lib/cache-strategy.ts
export interface CacheConfig {
  revalidate: number;
  description: string;
}

export const cacheStrategies = {
  // アクセス数多い:長いキャッシュ
  POPULAR: { revalidate: 86400, description: 'アクセス多数・24時間' } as CacheConfig,
  
  // 定期更新:中程度のキャッシュ
  REGULAR: { revalidate: 3600, description: '定期更新・1時間' } as CacheConfig,
  
  // 頻繁に更新:短いキャッシュ
  FREQUENT: { revalidate: 600, description: '頻繁更新・10分' } as CacheConfig,
  
  // リアルタイム性が必要:最短キャッシュ
  REALTIME: { revalidate: 60, description: 'リアルタイム・1分' } as CacheConfig,
};

/**
 * 商品の特性に応じてキャッシュ戦略を決定
 */
export function determineCacheStrategy(product: any): CacheConfig {
  // 在庫商品の場合、より頻繁に更新
  if (product.stock < 10) {
    return cacheStrategies.REALTIME;
  }

  // セール商品は価格変動が多い
  if (product.isOnSale) {
    return cacheStrategies.FREQUENT;
  }

  // 新商品は更新頻度が高い可能性
  const daysSinceRelease = Math.floor(
    (Date.now() - new Date(product.releaseDate).getTime()) / (1000 * 60 * 60 * 24)
  );
  if (daysSinceRelease < 30) {
    return cacheStrategies.FREQUENT;
  }

  // それ以外は通常戦略
  if (product.viewCount > 10000) {
    return cacheStrategies.POPULAR;
  }

  return cacheStrategies.REGULAR;
}
// pages/products/[id].tsx(改良版)
import { GetStaticProps } from 'next';
import { determineCacheStrategy } from '@/lib/cache-strategy';

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const id = params?.id as string;

  try {
    const product = await fetch(`https://api.example.com/products/${id}`)
      .then(res => res.json());

    const cacheConfig = determineCacheStrategy(product);

    return {
      props: {
        product,
        cacheInfo: cacheConfig.description,
        generatedAt: new Date().toISOString(),
      },
      revalidate: cacheConfig.revalidate,
    };
  } catch (error) {
    return {
      notFound: true,
      revalidate: 300,
    };
  }
};

業務パターン3:データベース連携とISR

実務ではデータベースから複数の関連データを取得して、ISRで効率的にキャッシュすることがよくあります。

// lib/db.ts(Prismaの例)
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function getProductWithRelations(productId: string) {
  return prisma.product.findUnique({
    where: { id: productId },
    include: {
      category: true,
      reviews: {
        take: 10,
        orderBy: { createdAt: 'desc' },
      },
      relatedProducts: {
        take: 5,
      },
      images: {
        orderBy: { order: 'asc' },
      },
    },
  });
}

export async function getPopularProductIds(limit: number = 50) {
  const products = await prisma.product.findMany({
    where: {
      isActive: true,
      viewCount: { gt: 100 },
    },
    select: { id: true },
    orderBy: { viewCount: 'desc' },
    take: limit,
  });

  return products.map(p => p.id);
}
// pages/products/[id].tsx(DB連携版)
import { GetStaticProps, GetStaticPaths } from 'next';
import { getProductWithRelations, getPopularProductIds } from '@/lib/db';
import { determineCacheStrategy } from '@/lib/cache-strategy';

interface Props {
  product: any;
  generatedAt: string;
  cacheStrategy: string;
}

export default function ProductPage({ product, generatedAt, cacheStrategy }: Props) {
  return (
    <div className=\"product-page\">
      <h1>{product.name}</h1>
      
      <div className=\"product-meta\">
        <span className=\"category\">{product.category.name}</span>
        <span className=\"price\">¥{product.price.toLocaleString()}</span>
      </div>

      <div className=\"images\">
        {product.images.map((img: any) => (
          <img key={img.id} src={img.url} alt={product.name} />
        ))}
      </div>

      <div className=\"description\">
        <p>{product.description}</p>
      </div>

      <div className=\"reviews\">
        <h2>レビュー({product.reviews.length}件)</h2>
        {product.reviews.map((review: any) => (
          <div key={review.id} className=\"review\">
            <p>{review.comment}</p>
            <span>★ {review.rating}/5</span>
          </div>
        ))}
      </div>

      <div className=\"debug-info\" style={{ fontSize: '0.8em', color: '#999' }}>
        <p>生成時刻: {new Date(generatedAt).toLocaleString('ja-JP')}</p>
        <p>キャッシュ戦略: {cacheStrategy}</p>
      </div>
    </div>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  const popularIds = await getPopularProductIds(100);

  return {
    paths: popularIds.map(id => ({
      params: { id },
    })),
    fallback: 'blocking',
  };
};

export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
  const id = params?.id as string;

  try {
    const product = await getProductWithRelations(id);

    if (!product) {
      return {
        notFound: true,
        revalidate: 300,
      };
    }

    const cacheConfig = determineCacheStrategy(product);

    return {
      props: {
        product,
        generatedAt: new Date().toISOString(),
        cacheStrategy: cacheConfig.description,
      },
      revalidate: cacheConfig.revalidate,
    };
  } catch (error) {
    console.error(`商品取得エラー (ID: ${id}):`, error);
    return {
      notFound: true,
      revalidate: 300,
    };
  }
};

業務パターン4:Webhook連携によるリアルタイム更新

CMSやEコマースプラットフォームからWebhookを受け取り、ISRをトリガーするパターンです。実務ではこれが最も多く使われます。

// pages/api/webhooks/shopify.ts
import { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';

interface ShopifyWebhookPayload {
  id: number;
  handle: string;
  [key: string]: any;
}

function verifyShopifyWebhook(req: NextApiRequest): boolean {
  const hmac = req.headers['x-shopify-hmac-sha256'] as string;
  if (!hmac) return false;

  const body = req.body;
  const message = typeof body === 'string' ? body : JSON.stringify(body);

  const generatedHash = crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET || '')
    .update(message, 'utf8')
    .digest('base64');

  return generatedHash === hmac;
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method not allowed' });
  }

  // Shopify署名検証
  if (!verifyShopifyWebhook(req)) {
    console.warn('無効なShopify webhook署名');
    return res.status(401).json({ message: '署名検証失敗' });
  }

  try {
    const payload: ShopifyWebhookPayload = req.body;
    
    // 商品ハンドルをIDに変換(実装例)
    const productId = payload.handle;

    // リバリデーションAPI呼び出し
    const revalidateUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/api/revalidate`;
    const revalidateRes = await fetch(revalidateUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        secret: process.env.REVALIDATE_SECRET,
        productId,
      }),
    });

    if (!revalidateRes.ok) {
      throw new Error(`リバリデーション失敗: ${revalidateRes.statusText}`);
    }

    console.log(`✓ 商品 ${productId} をリバリデーション`);

    return res.status(200).json({
      success: true,
      productId,
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    console.error('Webhook処理エラー:', error);
    return res.status(500).json({
      success: false,
      error: error instanceof Error ? error.message : '不明なエラー',
    });
  }
}

業務パターン5:ISRのモニタリングとログ

実務では、ISRの再生成が正常に機能しているか監視する必要があります。

// lib/isr-monitor.ts
interface RevalidationLog {
  timestamp: string;
  path: string;
  status: 'success' | 'failure';
  duration: number;
  error?: string;
}

const logs: RevalidationLog[] = [];

export function logRevalidation(
  path: string,
  status: 'success' | 'failure',
  duration: number,
  error?: string
) {
  const log: RevalidationLog = {
    timestamp: new Date().toISOString(),
    path,
    status,
    duration,
    error,
  };

  logs.push(log);

  // 古いログを削除(メモリ節約)
  if (logs.length > 1000) {
    logs.shift();
  }

  // エラーはSlackに通知
  if (status === 'failure') {
    notifySlack({
      text: `⚠️ ISRエラー: ${path}`,
      details: error,
      timestamp: new Date().toLocaleString('ja-JP'),
    });
  }
}

async function notifySlack(message: any) {
  if (!process.env.SLACK_WEBHOOK_URL) return;

  try {
    await fetch(process.env.SLACK_WEBHOOK_URL, {
      method: 'POST',
      body: JSON.stringify({
        text: message.text,
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: `${message.text}\n時刻: ${message.timestamp}\n詳細: \`${message.details}\``,
            },
          },
        ],
      }),
    });
  } catch (error) {
    console.error('Slack通知失敗:', error);
  }
}

export function getRevalidationLogs(limit: number = 100) {
  return logs.slice(-limit);
}

よくある応用パターン

パターン1:複数言語対応のISR

国際化(i18n)対応サイトでは、言語ごとにパスを分ける必要があります。

// pages/[lang]/products/[id].tsx
import { GetStaticProps, GetStaticPaths } from 'next';

const SUPPORTED_LANGUAGES = ['ja', 'en', 'zh'];

export const getStaticPaths: GetStaticPaths = async () => {
  const productIds = await getPopularProductIds(50);
  
  const paths = [];
  for (const lang of SUPPORTED_LANGUAGES) {
    for (const id of productIds) {
      paths.push({
        params: { lang, id },
      });
    }
  }

  return {
    paths,
    fallback: 'blocking',
  };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const { lang, id } = params as { lang: string; id: string };

  // 言語に応じたデータを取得
  const product = await fetch(
    `https://api.example.com/products/${id}?lang=${lang}`
  ).then(r => r.json());

  return {
    props: { product },
    revalidate: 3600,
  };
};

パターン2:段階的再生成(Stale-While-Revalidate)

ユーザーには古いコンテンツを素早く返し、バックグラウンドで新しいバージョンを準備するパターンです。

// middleware.ts(Next.js 12.2+)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

  // ISR対象ページに対してキャッシュヘッダーを設定
  if (request.nextUrl.pathname.match(/^\\/products\\/[^/]+$/)) {
    response.headers.set(
      'Cache-Control',
      'public, s-maxage=3600, stale-while-revalidate=86400'
    );
  }

  return response;
}

export const config = {
  matcher: '/products/:path*',
};

注意点と落とし穴

注意点1:revalidateの値を過小評価しない

revalidate: 1(1秒)などの短い値を設定すると、事実上動的生成と変わらず、ISRのメリットが失われます。また、バックエンドのAPI負荷も増加します。

// ❌ 悪い例
export const getStaticProps: GetStaticProps = async () => {
  return {
    props: { /* ... */ },
    revalidate: 1, // ほぼ毎秒再生成される
  };
};

// ✅ 良い例
export const getStaticProps: GetStaticProps = async () => {
  return {
    props: { /* ... */ },
    revalidate: 3600, // 1時間は十分実用的
  };
};

注意点2:fallbackのモードに注意

fallback: ‘blocking’を使うと、初回アクセスでページが生成されるまで待機します。これはSEOには良いですが、ユーザー体験は悪くなります。

// ❌ 遅いパターン
export const getStaticPaths: GetStaticPaths = async () => {
  return {
    paths: [],
    fallback: 'blocking', // すべてのページが初回アクセスで待機
  };
};

// ✅ バランス型パターン
export const getStaticPaths: GetStaticPaths = async () => {
  const popularIds = await getPopularProductIds(100);
  
  return {
    paths: popularIds.map(id => ({ params: { id } })),
    fallback: 'blocking', // 人気ページだけ事前生成、その他はSSR
  };
};

注意点3:外部API呼び出しのタイムアウト

ISRでビルド時やリバリデーション時にAPI呼び出しが失敗すると、ページ生成そのものが失敗します。必ずエラーハンドリングとタイムアウト設定を実装してください。

const FETCH_TIMEOUT = 10000; // 10秒

async function fetchWithTimeout(url: string, timeout: number = FETCH_TIMEOUT) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, {
      signal: controller.signal,
    });
    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const id = params?.id as string;

  try {
    const response = await fetchWithTimeout(
      `https://api.example.com/products/${id}`,
      5000
    );

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

    const product = await response.json();

    return {
      props: { product },
      revalidate: 3600,
    };
  } catch (error) {
    console.error('データ取得失敗:', error);
    
    // フォールバック:キャッシュを短くして再試行待機
    return {
      notFound: true,
      revalidate: 60, // 1分で再試行
    };
  }
};

注意点4:メモリリークとリソース問題

ISRの再生成では毎回ビルドプロセスが走るため、メモリを大量に消費する処理は避けるべきです。


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