Next.js のキャッシング戦略を業務で活用するパターンと実装方法
簡易的な解説
Next.js のキャッシング機能は、アプリケーションのパフォーマンスを大きく左右する重要な要素です。キャッシング戦略を適切に設計することで、データベースへのアクセス頻度を減らし、ユーザーへの応答速度を改善できます。
Next.js で提供されるキャッシング機構には、大きく分けて4つのレイヤーがあります。
- Request Memoization:同じリクエスト内での重複排除
- Data Cache:ビルド時に生成されたデータの再利用
- Full Route Cache:静的ルートの事前生成とキャッシング
- Router Cache:クライアント側のナビゲーション最適化
これらのキャッシング層を業務の要件に応じて組み合わせることが、実務での成功の鍵になります。
業務でのユースケース
ユースケース 1:ECサイトの商品一覧ページ
ECサイトの商品一覧は、完全には静的ではありませんが、数時間単位での更新であれば十分なケースが多くあります。ISR(Incremental Static Regeneration)を活用することで、定期的に再生成しながらも高速な応答を実現できます。
ユースケース 2:ニュースサイトのコンテンツ配信
記事の公開は編集者が行うものの、閲覧ユーザーに対しては静的ページとして高速配信したいケースです。On-demand ISR を使用することで、記事公開時に明示的にキャッシュを再生成します。
ユースケース 3:ユーザーごとのカスタマイズページ
ログインユーザーごとに表示内容が異なるダッシュボード等では、キャッシングとセッション管理を慎重に設計する必要があります。
実装コード
1. ISR を使用した商品一覧の実装
以下は、eコマースサイトで3時間ごとに再生成される商品一覧ページの実装例です。
// app/products/page.tsx
import { cache } from 'react';
interface Product {
id: string;
name: string;
price: number;
stock: number;
category: string;
}
// データベースから商品データを取得する関数
const fetchProducts = cache(async (): Promise => {
try {
const response = await fetch('https://api.example.com/products', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.API_SECRET_KEY}`,
},
// ここでキャッシュ時間を指定
next: { revalidate: 3600 }, // 1時間
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch products:', error);
// フォールバック処理
return [];
}
});
export const revalidate = 10800; // 3時間でISR実行
export default async function ProductsPage() {
const products = await fetchProducts();
return (
商品一覧
{products.map((product) => (
{product.name}
¥{product.price.toLocaleString('ja-JP')}
在庫: {product.stock}
カテゴリー: {product.category}
))}
);
}
2. On-demand ISR を使用した記事管理システム
記事の公開・更新時に明示的にキャッシュを再生成する実装です。API ルートで再検証エンドポイントを提供します。
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
interface RevalidatePayload {
secret: string;
articleId?: string;
paths?: string[];
}
export async function POST(request: NextRequest) {
const payload: RevalidatePayload = await request.json();
// 秘密鍵の検証(外部からの不正なリクエスト防止)
if (payload.secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json(
{ error: 'Invalid secret' },
{ status: 401 }
);
}
try {
// 特定の記事の詳細ページを再生成
if (payload.articleId) {
revalidatePath(`/articles/${payload.articleId}`);
revalidatePath('/articles'); // 一覧も再生成
}
// 複数のパスを指定して再生成
if (payload.paths) {
payload.paths.forEach((path) => {
revalidatePath(path);
});
}
return NextResponse.json(
{ message: 'Revalidation successful', revalidated: true },
{ status: 200 }
);
} catch (error) {
console.error('Revalidation error:', error);
return NextResponse.json(
{ error: 'Failed to revalidate' },
{ status: 500 }
);
}
}
記事公開時のスクリプト例:
// lib/publishArticle.ts
import { db } from '@/lib/database';
export async function publishArticle(
articleId: string,
content: string,
metadata: Record
) {
// 記事をデータベースに保存
const article = await db.articles.update({
id: articleId,
status: 'published',
publishedAt: new Date(),
content,
metadata,
});
// 記事公開時に自動的にキャッシュを再生成
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_APP_URL}/api/revalidate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
secret: process.env.REVALIDATE_SECRET,
articleId: article.id,
paths: ['/articles', `/articles/${article.id}`],
}),
}
);
if (!response.ok) {
console.error('Revalidation failed:', response.statusText);
}
} catch (error) {
console.error('Error triggering revalidation:', error);
}
return article;
}
3. API ルートでのキャッシング戦略
外部 API を呼び出すエンドポイントで、レスポンスをキャッシュする実装です。
// app/api/user-stats/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cache } from 'react';
interface UserStats {
userId: string;
totalPurchases: number;
totalSpent: number;
lastPurchaseDate: string;
memberSince: string;
}
const fetchUserStats = cache(async (userId: string): Promise => {
const response = await fetch(
`https://analytics-api.example.com/users/${userId}/stats`,
{
headers: {
'Authorization': `Bearer ${process.env.ANALYTICS_API_KEY}`,
},
next: { revalidate: 300 }, // 5分キャッシュ
}
);
if (!response.ok) {
throw new Error('Failed to fetch user stats');
}
return response.json();
});
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const userId = searchParams.get('userId');
if (!userId) {
return NextResponse.json(
{ error: 'userId parameter is required' },
{ status: 400 }
);
}
try {
const stats = await fetchUserStats(userId);
return NextResponse.json(stats, {
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
},
});
} catch (error) {
console.error('Error fetching user stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch stats' },
{ status: 500 }
);
}
}
4. ダイナミックルートのキャッシング
特定のパラメータに対してのみ静的生成を行い、その他は動的に処理する実装です。
// app/articles/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { db } from '@/lib/database';
interface ArticlePageProps {
params: {
slug: string;
};
}
interface Article {
id: string;
slug: string;
title: string;
content: string;
author: string;
publishedAt: Date;
views: number;
}
// ビルド時に生成する記事のスラッグを指定
export async function generateStaticParams() {
const articles = await db.articles.findPublished();
return articles.map((article) => ({
slug: article.slug,
}));
}
export const revalidate = 3600; // 1時間
async function fetchArticle(slug: string): Promise {
try {
return await db.articles.findBySlug(slug);
} catch (error) {
console.error(`Failed to fetch article with slug: ${slug}`, error);
return null;
}
}
export async function generateMetadata({ params }: ArticlePageProps) {
const article = await fetchArticle(params.slug);
if (!article) {
return {
title: 'Article Not Found',
};
}
return {
title: article.title,
description: article.content.substring(0, 160),
openGraph: {
title: article.title,
description: article.content.substring(0, 160),
type: 'article',
},
};
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const article = await fetchArticle(params.slug);
if (!article) {
notFound();
}
return (
{article.title}
By {article.author} on{' '}
{new Date(article.publishedAt).toLocaleDateString('ja-JP')}
Views: {article.views}
{article.content}
);
}
よくある応用パターン
パターン 1:複数キャッシュレイヤーの組み合わせ
データベースからのフェッチ結果をメモ化し、さらに ISR で再生成する戦略:
// lib/cachedQueries.ts
import { cache } from 'react';
const CACHE_DURATION = {
SHORT: 60, // 1分
MEDIUM: 300, // 5分
LONG: 3600, // 1時間
VERY_LONG: 86400, // 24時間
};
export const getCategoryWithProducts = cache(async (categoryId: string) => {
const response = await fetch(
`${process.env.API_URL}/categories/${categoryId}?include=products`,
{
next: { revalidate: CACHE_DURATION.MEDIUM },
headers: {
'Authorization': `Bearer ${process.env.API_KEY}`,
},
}
);
return response.json();
});
export const getTrendingItems = cache(async () => {
const response = await fetch(
`${process.env.API_URL}/trending`,
{
next: { revalidate: CACHE_DURATION.SHORT },
headers: {
'Authorization': `Bearer ${process.env.API_KEY}`,
},
}
);
return response.json();
});
パターン 2:条件付きキャッシング
ユーザーの状態やクエリパラメータに応じてキャッシング戦略を変更する実装:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const authToken = request.cookies.get('auth_token');
// ログイン状態によってキャッシュヘッダーを変更
if (authToken) {
// ログインユーザーのページはキャッシュしない
response.headers.set(
'Cache-Control',
'private, no-cache, no-store, must-revalidate'
);
} else {
// 未ログインユーザーのページはキャッシュ可能
response.headers.set(
'Cache-Control',
'public, s-maxage=3600, stale-while-revalidate=7200'
);
}
return response;
}
export const config = {
matcher: ['/dashboard/:path*', '/profile/:path*'],
};
パターン 3:バックグラウンド再検証
ユーザーリクエスト中に古いデータを返しながら、バックグラウンドで再検証する実装:
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get('q');
if (!query) {
return NextResponse.json(
{ error: 'Query parameter is required' },
{ status: 400 }
);
}
try {
const response = await fetch(
`${process.env.SEARCH_API_URL}/search?q=${encodeURIComponent(query)}`,
{
next: {
revalidate: 600, // 10分
},
}
);
const results = await response.json();
return NextResponse.json(results, {
headers: {
// stale-while-revalidateで古いデータを返しながら再検証
'Cache-Control':
'public, s-maxage=600, stale-while-revalidate=1200',
},
});
} catch (error) {
console.error('Search error:', error);
return NextResponse.json(
{ error: 'Search failed' },
{ status: 500 }
);
}
}
注意点
1. キャッシュの無効化のタイミング
ISR の再検証時間(revalidate)を短すぎる値に設定すると、サーバーへの負荷が増加します。業務要件との兼ね合いを考慮し、適切な値を設定することが重要です。一般的には以下を目安にします:
- リアルタイム性が必須:60~300秒
- 数時間の遅延が許容:3600~7200秒
- 1日以上の遅延が許容:86400秒以上
2. 秘密鍵の管理
On-demand ISR を使用する際は、`REVALIDATE_SECRET` 環境変数を安全に管理してください。本番環境では強力なランダムな値を使用し、Git に含めないよう注意します。
3. メモリリークの防止
React の `cache()` 関数は各リクエストの間でメモリをクリアします。しかし、グローバルなキャッシュ機構を実装する場合は、適切なメモリ管理が必要です。
4. キャッシュキーの設計
クエリパラメータが異なる場合、Next.js はそれを異なるキャッシュキーとして扱います。意図しないキャッシュの重複を防ぐため、URL 構造を慎重に設計してください。
5. 開発環境でのキャッシュの扱い
開発環境でキャッシュが有効になっていると、変更がすぐに反映されないことがあります。`.next` フォルダを削除するか、`next dev` コマンドで開発サーバーを実行することで対応します。
// キャッシュをクリアして開発サーバーを起動
rm -rf .next && npm run dev
6. Edge での キャッシング挙動
Vercel Edge Runtime などの CDN に展開する場合、キャッシング戦略が異なることがあります。本番環境での動作確認は必須です。
まとめ
Next.js のキャッシング機構は、適切に設計・運用することで、アプリケーションのパフォーマンスを大幅に向上させます。業務の要件に応じて、ISR、On-demand ISR、API ルートのキャッシングなどを組み合わせることが重要です。
実務では以下のポイントを押さえることが成功の鍵になります:
- 要件の確認:どの程度の遅延が許容されるか、どれだけのアクセスが想定されるか
- 段階的な導入:最初はシンプルな ISR から始め、必要に応じて複雑な戦略を追加
- モニタリング:キャッシュヒット率やサーバー負荷を監視し、必要に応じて調整
- テスト:開発環境と本番環境でのキャッシング動作の違いを確認
- ドキュメント化:チームメンバーがキャッシング戦略を理解できるよう記録
これらの実装パターンと注意点を参考に、自社の業務に最適なキャッシング戦略を構築してください。

