Documentation Next.js

はじめに

Next.jsでは、適切なキャッシュ戦略を実装することで、アプリケーションのパフォーマンスを大幅に向上させることができます。この記事では、Next.js 14以降のApp Routerを中心に、様々なキャッシュ戦略とその実装方法を解説します。

Next.jsのキャッシュレイヤー

Next.jsには複数のキャッシュレイヤーがあります。

レイヤー説明持続期間
Request Memoization同一リクエスト内でのfetch重複排除リクエスト中のみ
Data Cachefetchリクエストの結果をキャッシュ永続(明示的に無効化まで)
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ヘッダーでエッジキャッシュを最大化

参考文献

円