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

