Next.js ISR(増分静的再生成)の実務活用ガイド|実装コード付き

React / Next.js

Next.js ISR(増分静的再生成)の実務活用ガイド|実装コード付き

こんにちは。今日はNext.jsのISR(Incremental Static Regeneration)について、実務レベルでの使い方を詳しく解説していきます。

ISRは一度は聞いたことがあるけど、実際のプロジェクトでどう活用するかよくわからない、という開発者は多いのではないでしょうか。理論的には理解できても、本番環境での運用を考えると判断が難しいということもあるかもしれません。この記事では、実務で実際に使用されるコードを示しながら、その活用方法を紹介します。

ISRとは何か|簡易的な解説

ISRは、Next.jsの静的生成(SSG)と動的生成の中間的な手法です。ビルド時に全ページを静的生成するのではなく、一定時間後に自動的にページを再生成する仕組みです。

従来のSSG では、コンテンツを更新するたびに全体をビルドし直す必要がありました。一方、ISRを使うと、特定のページだけを自動的に再生成できるため、ビルドの手間を大幅に削減できます。

基本的な仕組みは以下の通りです:

  • 初回アクセス時:ページが静的に生成される
  • 再検証期間内:キャッシュされたページが配信される
  • 再検証期間経過後:バックグラウンドで自動的にページが再生成される
  • 再生成中:古いページがユーザーに配信され続ける(stale-while-revalidate)

これにより、常に新しいコンテンツを提供しつつ、ビルドの手間を削減できるというわけです。

業務でのユースケース

ISRが活躍する場面は意外と多いものです。実務では以下のようなケースでよく使用されます。

ユースケース1:ブログサイトの記事管理

ブログサイトでは、毎日複数の記事が更新されます。SSGだけを使うと、記事更新のたびにビルドが必要になり、デプロイまでの時間が長くなります。ISRを使えば、記事の公開から数秒~数分で自動的にページが生成されます。

ユースケース2:ECサイトの商品ページ

商品の在庫状況や価格は頻繁に変動します。ISRなら、定期的に商品情報を自動更新することができます。例えば、5分ごとに再生成するように設定すれば、常に最新の在庫情報を提供できます。

ユースケース3:会社の採用情報ページ

求人情報は時間とともに変化します。ISRを使うことで、新しい求人がデータベースに登録されたら、自動的にページが更新されるようにできます。

ユースケース4:ニュースサイト

リアルタイムに更新される必要はないが、できるだけ新しい情報を提供したいというサイトに最適です。1時間ごとに再生成するなど、バランスの取れた設定ができます。

実装コード|実務レベルの例

基本的なISR実装

まずは、基本的なISRの実装方法を見てみましょう。Next.js 13以降のApp Routerを使った例です。

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

interface BlogPostProps {
  params: {
    slug: string;
  };
}

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

// ブログ記事を取得する関数
async function getBlogPost(slug: string): Promise {
  try {
    const res = await fetch(`https://api.example.com/blog/${slug}`, {
      next: { revalidate: 3600 } // 1時間ごとに再検証
    });

    if (!res.ok) {
      return null;
    }

    return res.json();
  } catch (error) {
    console.error('Failed to fetch blog post:', error);
    return null;
  }
}

// ビルド時に事前生成するパスを指定
export async function generateStaticParams() {
  try {
    const res = await fetch('https://api.example.com/blog/list', {
      next: { revalidate: 3600 }
    });

    const posts: BlogPost[] = await res.json();

    return posts.map((post) => ({
      slug: post.slug,
    }));
  } catch (error) {
    console.error('Failed to generate static params:', error);
    return [];
  }
}

export async function generateMetadata({ params }: BlogPostProps) {
  const post = await getBlogPost(params.slug);

  if (!post) {
    return {
      title: 'Not Found',
    };
  }

  return {
    title: post.title,
    description: post.content.substring(0, 160),
    openGraph: {
      title: post.title,
      description: post.content.substring(0, 160),
      url: `https://example.com/blog/${post.slug}`,
    },
  };
}

export default async function BlogPost({ params }: BlogPostProps) {
  const post = await getBlogPost(params.slug);

  if (!post) {
    notFound();
  }

  return (
    

{post.title}

by {post.author}
{post.content}
); }\n

実務的なISR実装|キャッシング戦略付き

より実務的な例として、キャッシング戦略を含めた実装を見てみましょう。複数の外部APIを使用する場合の実装です。

// lib/cache.ts
interface CacheOptions {
  revalidate: number;
  tags?: string[];
}

export async function cachedFetch(
  url: string,
  options: CacheOptions
): Promise {
  const cacheKey = new URL(url).pathname;

  try {
    const response = await fetch(url, {
      next: {
        revalidate: options.revalidate,
        tags: options.tags || [cacheKey],
      },
    });

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

    return response.json();
  } catch (error) {
    console.error(`Cache fetch failed for ${url}:`, error);
    throw error;
  }
}\n
// app/products/[id]/page.tsx
import { cachedFetch } from '@/lib/cache';
import { notFound } from 'next/navigation';

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  inStock: boolean;
  imageUrl: string;
  category: string;
}

async function getProduct(id: string): Promise {
  try {
    return await cachedFetch(
      `https://api.example.com/products/${id}`,
      {
        revalidate: 300, // 5分ごとに再検証
        tags: [`product-${id}`],
      }
    );
  } catch (error) {
    console.error(`Failed to fetch product ${id}:`, error);
    return null;
  }
}

async function getAllProductIds(): Promise {
  try {
    const response = await cachedFetch<{ ids: string[] }>(
      'https://api.example.com/products/list',
      {
        revalidate: 3600, // 1時間ごとに再検証
        tags: ['product-list'],
      }
    );
    return response.ids;
  } catch (error) {
    console.error('Failed to fetch product list:', error);
    return [];
  }
}

export async function generateStaticParams() {
  const ids = await getAllProductIds();

  // 最初は最新の50商品だけ生成して、残りはオンデマンドで生成
  return ids.slice(0, 50).map((id) => ({
    id,
  }));
}

export const dynamicParams = true; // 事前に生成されていないパスはオンデマンドで生成

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

  if (!product) {
    notFound();
  }

  return (
    
{product.name}

{product.name}

¥{product.price.toLocaleString('ja-JP')}

\n

{product.description}

\n
\n \n {product.inStock ? '在庫あり' : '在庫なし'}\n \n
\n \n カートに追加\n \n
\n
\n
\n );\n}\n

On-Demand ISRの実装

データベースの更新に応じてISRを手動でトリガーする場合もあります。これを「On-Demand ISR」と呼びます。

// app/api/revalidate/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(
      { message: 'Unauthorized' },
      { status: 401 }
    );
  }

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

    if (type === 'tag' && id) {
      // タグベースの再検証
      revalidateTag(`product-${id}`);
      return NextResponse.json({
        revalidated: true,
        message: `Product ${id} revalidated`,
      });
    } else if (type === 'path' && path) {
      // パスベースの再検証
      revalidatePath(path);
      return NextResponse.json({
        revalidated: true,
        message: `Path ${path} revalidated`,
      });
    } else {
      return NextResponse.json(
        { message: 'Missing type or id/path' },
        { status: 400 }
      );
    }
  } catch (error) {
    console.error('Revalidation error:', error);
    return NextResponse.json(
      { message: 'Revalidation failed' },
      { status: 500 }
    );
  }\n}\n

CMSやデータベースから更新通知を受け取った時は、このエンドポイントを呼び出して、ISRを手動でトリガーします。

// lib/revalidate.ts
export async function revalidateProduct(productId: string) {
  try {
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_BASE_URL}/api/revalidate`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Revalidate-Secret': process.env.REVALIDATE_SECRET || '',
        },
        body: JSON.stringify({
          type: 'tag',
          id: productId,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Revalidation failed: ${response.statusText}`);
    }

    console.log(`Product ${productId} revalidation requested`);
  } catch (error) {
    console.error('Failed to revalidate product:', error);
    throw error;
  }\n}\n

よくある応用パターン

パターン1:複数のAPIから一括取得する場合

実務では複数のAPIを同時に呼び出して、1つのページを構成することがよくあります。

// app/dashboard/page.tsx
interface DashboardData {
  stats: {
    totalUsers: number;
    activeUsers: number;
    revenue: number;
  };
  recentOrders: Array<{
    id: string;
    total: number;
    createdAt: string;
  }>;
  topProducts: Array<{
    id: string;
    name: string;
    sales: number;
  }>;
}\n\nasync function getDashboardData(): Promise {\n  try {\n    // 複数のAPIを並列実行\n    const [statsRes, ordersRes, productsRes] = await Promise.all([\n      fetch('https://api.example.com/stats', {\n        next: { revalidate: 600 }, // 10分\n      }),\n      fetch('https://api.example.com/orders/recent', {\n        next: { revalidate: 300 }, // 5分\n      }),\n      fetch('https://api.example.com/products/top', {\n        next: { revalidate: 1800 }, // 30分\n      }),\n    ]);\n\n    const [stats, orders, products] = await Promise.all([\n      statsRes.json(),\n      ordersRes.json(),\n      productsRes.json(),\n    ]);\n\n    return {\n      stats,\n      recentOrders: orders,\n      topProducts: products,\n    };\n  } catch (error) {\n    console.error('Failed to fetch dashboard data:', error);\n    throw error;\n  }\n}\n\nexport default async function Dashboard() {\n  const data = await getDashboardData();\n\n  return (\n    
\n

ダッシュボード

\n\n
\n
\n

総ユーザー数

\n

{data.stats.totalUsers}

\n
\n
\n

アクティブユーザー

\n

{data.stats.activeUsers}

\n
\n
\n

売上

\n

¥{data.stats.revenue.toLocaleString('ja-JP')}

\n
\n
\n\n
\n
\n

最近の注文

\n
\n {data.recentOrders.map((order) => (\n
\n {order.id}\n ¥{order.total.toLocaleString('ja-JP')}\n
\n ))}\n
\n
\n\n
\n

トップ商品

\n
\n {data.topProducts.map((product) => (\n
\n {product.name}\n {product.sales}売上\n
\n ))}\n
\n
\n
\n
\n );\n}\n

パターン2:動的ルートセグメントの処理

複数の動的パラメータを持つページでISRを実装する場合です。

// app/category/[category]/[subcategory]/page.tsx\ninterface CategoryPageProps {\n  params: {\n    category: string;\n    subcategory: string;\n  };\n}\n\nasync function getCategoryProducts(\n  category: string,\n  subcategory: string\n) {\n  const res = await fetch(\n    `https://api.example.com/products?category=${category}&subcategory=${subcategory}`,\n    {\n      next: { revalidate: 600 },\n    }\n  );\n\n  if (!res.ok) {\n    return null;\n  }\n\n  return res.json();\n}\n\nexport async function generateStaticParams() {\n  // すべてのカテゴリ&サブカテゴリの組み合わせを取得\n  const res = await fetch('https://api.example.com/categories/tree');\n  const categories = await res.json();\n\n  const paths: Array<{ category: string; subcategory: string }> = [];\n\n  for (const cat of categories) {\n    for (const subcat of cat.subcategories) {\n      paths.push({\n        category: cat.slug,\n        subcategory: subcat.slug,\n      });\n    }\n  }\n\n  return paths;\n}\n\nexport const dynamicParams = true;\n\nexport default async function CategoryPage({ params }: CategoryPageProps) {\n  const products = await getCategoryProducts(\n    params.category,\n    params.subcategory\n  );\n\n  if (!products) {\n    return 
カテゴリが見つかりません
;\n }\n\n return (\n
\n

商品一覧

\n
\n {products.map((product: any) => (\n
\n \n

{product.name}

\n

¥{product.price}

\n
\n ))}\n
\n
\n );\n}\n

実務での注意点

注意点1:revalidate値の設定

revalidate値はコンテンツの更新頻度に応じて慎重に決める必要があります。

  • リアルタイム性が必要(在庫、価格):300~600秒(5~10分)
  • 時間単位の更新でOK(ブログ、ニュース):3600~7200秒(1~2時間)
  • 日単位の更新でOK(静的コンテンツ):86400秒(1日)以上

短すぎると再生成の負荷が高まり、長すぎると古い情報が表示されるリスクがあります。

注意点2:エラーハンドリング

ISRではAPIが失敗した場合の処理が重要です。前回キャッシュされたページを返すのか、エラーページを返すのかを明確にしておきましょう。

// より堅牢なエラーハンドリング例\nasync function getProductWithFallback(id: string) {\n  try {\n    const res = await fetch(\n      `https://api.example.com/products/${id}`,\n      {\n        next: { revalidate: 300 },\n        // タイムアウトを設定\n        signal: AbortSignal.timeout(5000),\n      }\n    );\n\n    if (!res.ok) {\n      console.warn(`Product API returned ${res.status}`);\n      // キャッシュされたデータを返す(stale-while-revalidate)\n      return null;\n    }\n\n    return res.json();\n  } catch (error) {\n    // ネットワークエラーやタイムアウトの場合\n    console.error('Product fetch error:', error);\n    return null;\n  }\n}\n

注意点3:generateStaticParams の最適化

すべてのパスを事前生成しようとすると、ビルド時間が長くなってしまいます。動的パラメータが多い場合は、以下の戦略を取りましょう。

  • アクセス数が多いものだけ事前生成:最初の50~100件に限定
  • dynamicParams = true:事前生成されていないパスはオンデマンドで生成
  • fallback: ‘blocking’と同等の動作:初回アクセス時は時間がかかるが、その後はキャッシュされる

注意点4:環境変数とシークレットキー

On-Demand ISRを使う場合、必ずシークレットキーで保護してください。

// .env.local\nREVALIDATE_SECRET=your-super-secret-key-here-min-32-chars\n

注意点5:キャッシュヘッダーの理解

ISRはブラウザキャッシュとサーバーサイドキャッシュの両方に影響します。

// キャッシュヘッダーを明示的に設定する場合\nexport async function generateMetadata({ params }: BlogPostProps) {\n  return {\n    title: 'My Blog Post',\n    // Vercelではこれが自動的に設定される\n    // 他の環境では手動設定が必要な場合がある\n  };\n}\n

注意点6:本番環境での監視

ISRの再生成がうまくいっているか、定期的に監視することが重要です。

// lib/monitoring.ts\nimport { logger } from '@/lib/logger';\n\nexport async function monitorRevalidation(\n  path: string,\n  startTime: number\n) {\n  const duration = Date.now() - startTime;\n\n  logger.info('Revalidation completed', {\n    path,\n    duration,\n    timestamp: new Date().toISOString(),\n  });\n\n  // 異常に遅い場合はアラート\n  if (duration > 30000) {\n    logger.warn('Slow revalidation detected', {\n      path,\n      duration,\n    });\n  }\n}\n

まとめ

Next.js ISRは、静的生成の高速性と動的生成の柔軟性を兼ね備えた強力な機能です。実務では以下のポイントを押さえることが重要です:

  • 適切なrevalidate値の設定:コンテンツの更新頻度に応じて調整する
  • generateStaticParams の最適化:すべてを事前生成するのではなく、重要なパスに絞る
  • エラーハンドリング:APIの失敗に備えた処理を用意する
  • On-Demand ISRの活用:重要な更新は手動でトリガーできるようにする
  • 本番環境での監視:再生成がうまくいっているか定期的に確認する

これらのポイントを意識することで、パフォーマンスと鮮度のバランスが取れた、実務レベルのNext.jsアプリケーションを構築できます。

ISRはNext.jsの数ある機能の中でも特に実用的なものです。ブログ、ECサイト、ニュースサイトなど、様々なプロジェクトで活躍します。ぜひプロジェクトに合わせて活用してみてください。

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