はじめに
Next.jsでは、適切なキャッシュ戦略を実装することで、アプリケーションのパフォーマンスを大幅に向上させることができます。この記事では、Next.js 14以降のApp Routerを中心に、様々なキャッシュ戦略とその実装方法を解説します。
Next.jsのキャッシュレイヤー
Next.jsには複数のキャッシュレイヤーがあります。
| レイヤー | 説明 | 持続期間 |
|---|---|---|
| Request Memoization | 同一リクエスト内でのfetch重複排除 | リクエスト中のみ |
| Data Cache | fetchリクエストの結果をキャッシュ | 永続(明示的に無効化まで) |
| Full Route Cache | レンダリング結果をキャッシュ | 永続(再デプロイまで) |
| Router Cache | クライアント側のルートキャッシュ | セッション中 |
App Routerでのキャッシュ制御
静的レンダリング(デフォルト)
// app/posts/page.tsx
// デフォルトで静的にレンダリングされ、キャッシュされる
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map((post: { id: string; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
動的レンダリング
// app/dashboard/page.tsx
// リクエストごとに実行される
async function getUserData() {
const res = await fetch('https://api.example.com/user', {
cache: 'no-store', // キャッシュを無効化
});
return res.json();
}
export default async function DashboardPage() {
const user = await getUserData();
return <div>Welcome, {user.name}</div>;
}
// または動的関数を使用
import { cookies, headers } from 'next/headers';
export default async function DashboardPage() {
const cookieStore = cookies(); // 動的関数の使用で自動的に動的レンダリング
const session = cookieStore.get('session');
return <div>Session: {session?.value}</div>;
}
ISR(Incremental Static Regeneration)
// app/blog/[slug]/page.tsx
// 時間ベースの再検証
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 }, // 60秒ごとに再検証
});
return res.json();
}
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
// 静的パラメータの生成
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then((res) =>
res.json()
);
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
オンデマンド再検証
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
// シークレットキーの検証
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const path = searchParams.get('path');
const tag = searchParams.get('tag');
try {
if (path) {
// 特定のパスを再検証
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}
if (tag) {
// タグベースの再検証
revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag });
}
return NextResponse.json(
{ message: 'Missing path or tag parameter' },
{ status: 400 }
);
} catch (error) {
return NextResponse.json(
{ message: 'Error revalidating' },
{ status: 500 }
);
}
}
タグベースのキャッシュ管理
// lib/api.ts
// タグを使ったfetch
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: {
tags: ['posts'], // タグを設定
revalidate: 3600, // 1時間ごとに再検証
},
});
return res.json();
}
export async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: {
tags: ['posts', `post-${slug}`], // 複数のタグを設定
revalidate: 3600,
},
});
return res.json();
}
// 特定の記事を更新した場合
// revalidateTag('post-my-article') で該当記事のみ再検証
// revalidateTag('posts') で全記事を再検証
SWRによるクライアントキャッシュ
基本的な使い方
// hooks/usePosts.ts
'use client';
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export function usePosts() {
const { data, error, isLoading, mutate } = useSWR('/api/posts', fetcher, {
revalidateOnFocus: true, // フォーカス時に再検証
revalidateOnReconnect: true, // 再接続時に再検証
refreshInterval: 30000, // 30秒ごとに自動更新
dedupingInterval: 2000, // 2秒以内の重複リクエストを排除
});
return {
posts: data,
isLoading,
isError: error,
refresh: mutate,
};
}
楽観的更新
// components/PostList.tsx
'use client';
import useSWR, { useSWRConfig } from 'swr';
interface Post {
id: string;
title: string;
content: string;
}
export function PostList() {
const { data: posts, mutate } = useSWR<Post[]>('/api/posts');
const { mutate: globalMutate } = useSWRConfig();
const handleDelete = async (postId: string) => {
// 楽観的に更新(UIを即座に更新)
mutate(
posts?.filter((post) => post.id !== postId),
false // 再検証をスキップ
);
try {
await fetch(`/api/posts/${postId}`, { method: 'DELETE' });
// 成功したら再検証
mutate();
} catch (error) {
// エラー時は元に戻す
mutate();
alert('削除に失敗しました');
}
};
const handleCreate = async (newPost: Omit<Post, 'id'>) => {
// 楽観的に追加
const tempId = `temp-${Date.now()}`;
const optimisticPost = { ...newPost, id: tempId };
mutate([...(posts || []), optimisticPost], false);
try {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
const createdPost = await res.json();
// 実際のデータで置き換え
mutate(
posts?.map((post) => (post.id === tempId ? createdPost : post)),
false
);
} catch (error) {
mutate(); // エラー時は再フェッチ
}
};
if (!posts) return <div>Loading...</div>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
<button onClick={() => handleDelete(post.id)}>削除</button>
</li>
))}
</ul>
);
}
SWRの高度な設定
// lib/swr-config.tsx
'use client';
import { SWRConfig } from 'swr';
import { ReactNode } from 'react';
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const error = new Error('An error occurred while fetching the data.');
throw error;
}
return res.json();
};
export function SWRProvider({ children }: { children: ReactNode }) {
return (
<SWRConfig
value={{
fetcher,
revalidateOnFocus: process.env.NODE_ENV === 'production',
revalidateOnReconnect: true,
shouldRetryOnError: true,
errorRetryCount: 3,
errorRetryInterval: 5000,
dedupingInterval: 2000,
// キャッシュプロバイダー(localStorage使用)
provider: () => {
const map = new Map(
JSON.parse(localStorage.getItem('swr-cache') || '[]')
);
window.addEventListener('beforeunload', () => {
localStorage.setItem(
'swr-cache',
JSON.stringify(Array.from(map.entries()))
);
});
return map as Map<string, any>;
},
}}
>
{children}
</SWRConfig>
);
}
HTTPキャッシュヘッダーの設定
next.config.jsでの設定
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
// 静的アセット用
source: '/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
// 画像用
source: '/images/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=86400, stale-while-revalidate=604800',
},
],
},
{
// API用
source: '/api/:path*',
headers: [
{
key: 'Cache-Control',
value: 'no-store, max-age=0',
},
],
},
];
},
};
module.exports = nextConfig;
APIルートでの動的キャッシュ
// app/api/products/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const products = await getProducts();
return NextResponse.json(products, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
},
});
}
// s-maxage: CDNでのキャッシュ時間(60秒)
// stale-while-revalidate: 古いキャッシュを返しながら再検証する期間(300秒)
CDNとエッジキャッシュ
Vercel Edge Configの活用
// lib/edge-config.ts
import { get } from '@vercel/edge-config';
export async function getFeatureFlags() {
const flags = await get<{
newFeature: boolean;
maintenance: boolean;
}>('featureFlags');
return flags || { newFeature: false, maintenance: false };
}
// app/page.tsx
import { getFeatureFlags } from '@/lib/edge-config';
export default async function Page() {
const flags = await getFeatureFlags();
if (flags.maintenance) {
return <MaintenancePage />;
}
return (
<main>
{flags.newFeature && <NewFeature />}
<Content />
</main>
);
}
Edge Middlewareでのキャッシュ制御
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// 静的ページにキャッシュヘッダーを追加
if (request.nextUrl.pathname.startsWith('/blog')) {
response.headers.set(
'Cache-Control',
'public, s-maxage=3600, stale-while-revalidate=86400'
);
}
// ユーザー固有のページはキャッシュしない
if (request.nextUrl.pathname.startsWith('/dashboard')) {
response.headers.set('Cache-Control', 'private, no-store');
}
return response;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
React Query/TanStack Queryの活用
// lib/query-client.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ReactNode, useState } from 'react';
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分間は新鮮とみなす
gcTime: 5 * 60 * 1000, // 5分間キャッシュを保持
refetchOnWindowFocus: false,
retry: 3,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// hooks/usePosts.ts
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface Post {
id: string;
title: string;
}
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts');
return res.json() as Promise<Post[]>;
},
});
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: Omit<Post, 'id'>) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
return res.json();
},
onSuccess: () => {
// 投稿一覧を再取得
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
キャッシュ戦略の選択ガイド
| ユースケース | 推奨戦略 | 再検証方法 |
|---|---|---|
| ブログ記事 | ISR + タグ | コンテンツ更新時にrevalidateTag |
| 商品一覧 | ISR (60秒) | 時間ベース |
| ユーザーダッシュボード | 動的 + SWR | クライアントで随時更新 |
| 設定ページ | 動的 (no-store) | 常に最新を取得 |
| ニュースフィード | SWR + リアルタイム | WebSocket併用 |
まとめ
Next.jsのキャッシュ戦略を効果的に活用するポイントをまとめます。
- 静的コンテンツ: デフォルトのキャッシュを活用し、ISRで定期更新
- 動的コンテンツ:
cache: 'no-store'で常に最新を取得 - クライアントデータ: SWRやReact Queryで効率的にキャッシュ
- オンデマンド更新: タグベースの再検証で必要な時だけ更新
- CDN活用: 適切なCache-Controlヘッダーでエッジキャッシュを最大化