C
Next.js/SEO/Lesson 15

Revalidation戦略 — revalidatePath · revalidateTag · 時間ベース

30分·theory
このチャプター
2/2
TypeScript

Revalidation戦略 — revalidatePath · revalidateTag · 時間ベース

💡 なぜ学ぶのか? — 「新しい記事を書いたのに表示されない」バグの第1位の原因

🎯 Next.js の fetch はデフォルトでキャッシュされます。mutation 後に無効化しなければ、ユーザーは古いデータを見続けます。
💼 4つのツール — `revalidatePath`・`revalidateTag`・`revalidate: N`・`cache: 'no-store'` — それぞれ用途が異なります。
誤った使い方をすると、キャッシュ効果ゼロ (毎リクエスト SSR) か、データが永久に stale になるかの二極端に陥ります。
🔗 Server Action・Webhook・管理者ページなど、mutation フローにおいて正確な無効化タイミングが重要です。
📈 **一行ルール**: データ fetch に `tags` を付け、mutation Server Action の末尾で `revalidateTag(...)` を呼ぶ — これが90%の答えです。
🏢 실무에서는
ブログ記事を投稿 → 一覧ページと詳細ページを両方無効化。コメント追加 → コメントセクションのみ無効化。管理者が商品価格を変更 → その商品カード・検索結果・カテゴリページをすべて無効化。シナリオごとに使うツールが異なります。

4つのツール — 使い分けマトリクス

1. 4つのツール整理

ツール場所効果
revalidate: N (fetchオプション)ページ・コンポーネントビルド時にキャッシュ、N秒後にバックグラウンドで再生成 (ISR)
cache: 'no-store'ページ・コンポーネントキャッシュなし、リクエストごとにSSR
revalidatePath('/x')Server Action・Route Handler/xページのすべてのfetchキャッシュを無効化
revalidateTag('tag')Server Action・Route Handler'tag'がタグ付けされたfetchのみを正確に無効化

2. tagsシステム — 最も強力なパターン

ts
// 📁 app/posts/page.tsx — fetchにタグを付与
const posts = await fetch('https://api/posts', {
  next: { tags: ['posts'] },
}).then(r => r.json());
ts
// 📁 app/posts/actions.ts — ミューテーション後にそのタグを無効化
'use server';
import { revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: formData.get('title') as string } });
  revalidateTag('posts');
  // 'posts'タグが付いたすべてのfetchキャッシュを無効化(ページ・コンポーネント問わず)
}

revalidatePathより正確 — 複数ページのfetchに同じタグを付けておけば一度にまとめて無効化できる。

3. 階層的タグ戦略

ts
// 記事一覧ページ
fetch('/api/posts', { next: { tags: ['posts'] } });

// 記事詳細ページ
fetch(`/api/posts/${id}`, { next: { tags: ['posts', `post-${id}`] } });

// コメント
fetch(`/api/posts/${id}/comments`, { next: { tags: [`post-${id}`, `comments-${id}`] } });
ts
// コメント追加 → その記事のコメントのみ無効化(記事一覧はそのまま)
revalidateTag(`comments-${id}`);

// 記事編集 → その記事と一覧の両方を無効化
revalidateTag('posts');       // 一覧
revalidateTag(`post-${id}`);  // 詳細

// 記事削除 → すべての記事関連キャッシュを無効化
revalidateTag('posts');

4. revalidatePath — パス単位の無効化

ts
revalidatePath('/posts');                    // /postsページのすべてのfetch
revalidatePath('/posts/[slug]', 'page');     // 動的パス全体
revalidatePath('/blog', 'layout');           // layoutを含む(すべての子コンポーネント)

tagsほど正確ではないが、マイグレーションや一時対応に有用。

5. 時間ベースrevalidate (ISR)

ts
fetch('https://api/posts', {
  next: { revalidate: 60 },  // 60秒間キャッシュ、その後バックグラウンドで再生成
});

// ページレベル — ページ内すべてのfetchの最大revalidate
export const revalidate = 60;

ミューテーションがないか少ないデータ(ブログ・ドキュメント・ランディングページ)に適している。

6. まとめ — 意思決定フロー

code
ミューテーションが頻繁に発生する?
├─ はい → tags + revalidateTag(正確な無効化)
└─ いいえ
   └─ データが時間で失効する?
      ├─ はい → revalidate: N (ISR)
      └─ いいえ → デフォルト force-cache(永続キャッシュ)

ユーザー固有のデータ?
└─ cache: 'no-store'(毎回SSR)
💻 🅰️ tagsなし — revalidatePathのみを使う
// ❌ revalidatePath のみ — すべての関連パスを知っている必要がある

// 📁 app/posts/page.tsx
async function fetchPosts() {
  return fetch('https://api/posts').then(r => r.json());
  // デフォルトの force-cache
}

// 📁 app/sidebar/RecentPosts.tsx (他のコンポーネントでも使用)
async function fetchRecentPosts() {
  return fetch('https://api/posts?limit=5').then(r => r.json());
}

// 📁 app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: formData.get('title') as string } });

  // すべての関連パスを一つずつ無効化
  revalidatePath('/posts');           // 記事リスト
  revalidatePath('/');                // ホームに RecentPosts がある
  revalidatePath('/sidebar', 'layout'); // サイドバーに RecentPosts がある
  // 一箇所でも漏れると、そのページには古いデータが表示される
}

// 欠点:
// - データを消費するすべてのページを mutation コードが知っている必要がある
// - 新しいページを追加すると mutation コードも修正が必要
// - サイドバー・フッターなどグローバルコンポーネントが関連データを使用する場合、レイアウト全体が無効化される
💻 🅱️ tagsシステム — データ単位の無効化
// ✅ tags + revalidateTag — データ単位での無効化

// 📁 app/posts/page.tsx
async function fetchPosts() {
  return fetch('https://api/posts', {
    next: { tags: ['posts'] },
  }).then(r => r.json());
}

// 📁 app/posts/[id]/page.tsx
async function fetchPost(id: string) {
  return fetch(`https://api/posts/${id}`, {
    next: { tags: ['posts', `post-${id}`] },
  }).then(r => r.json());
}

// 📁 app/posts/[id]/Comments.tsx
async function fetchComments(id: string) {
  return fetch(`https://api/posts/${id}/comments`, {
    next: { tags: [`post-${id}`, `comments-${id}`] },
  }).then(r => r.json());
}

// 📁 app/sidebar/RecentPosts.tsx (どこからでも)
async function fetchRecentPosts() {
  return fetch('https://api/posts?limit=5', {
    next: { tags: ['posts'] },
  }).then(r => r.json());
}

// 📁 app/posts/actions.ts — mutation コードはタグだけを知っていればよい
'use server';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: formData.get('title') as string } });
  revalidateTag('posts');
  // 'posts' タグが付いたすべての fetch (リスト・ホーム RecentPosts・サイドバーなど) が自動的に無効化される
}

export async function updatePost(id: number, formData: FormData) {
  await db.post.update({
    where: { id },
    data: { title: formData.get('title') as string },
  });
  revalidateTag('posts');           // リスト (タイトル変更)
  revalidateTag(`post-${id}`);      // 詳細 + コメント (コメントにも post-{id} タグがあるため)
}

export async function addComment(postId: number, formData: FormData) {
  await db.comment.create({
    data: { postId, text: formData.get('text') as string },
  });
  revalidateTag(`comments-${postId}`); // その記事のコメントのみ (記事全体リスト・他の記事はそのまま)
}

export async function deletePost(id: number) {
  await db.post.delete({ where: { id } });
  revalidateTag('posts');
  revalidateTag(`post-${id}`);
  redirect('/posts');
}

// 利点:
// - mutation コードがデータ使用箇所を知る必要がない
// - 新しいページに同じ fetch を追加しても mutation の修正は不要
// - 精度向上 (必要なタグのみ、無関係なページのキャッシュは維持)
// - オンデマンド可能 — webhook を受けて revalidateTag を呼び出すことも可能

💡 💡 Revalidation実践の5つのヒント

1. 基本方針 — fetchにtags、ミューテーションにrevalidateTag
90%のケースはこのパターン一つで完結する。

2. revalidatePathはフォールバック用
tagsが難しい場合や素早いマイグレーションが必要な場合のみ使用。精度は低い。

3. cache: 'no-store'は本当にユーザー固有のデータにのみ使う
乱用するとすべてのページがSSRになりサーバー負荷増加・低速化につながる。代わりにその部分だけをClient Componentに分離する。

4. revalidate: Nのヒューリスティック

code
1分に1回未満の更新    → revalidate: 60
1時間に数回の更新     → revalidate: 300~600
ほとんど変わらない    → force-cache(デフォルト)+ tags

5. オンデマンドパターン — Webhookで外部から無効化

ts
// app/api/revalidate/route.ts — 外部から呼び出し
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get('secret');
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }
  const { tag } = await req.json();
  revalidateTag(tag);
  return NextResponse.json({ revalidated: true });
}

CMSや外部システムがデータを更新する際に通知を送り、自動的に無効化される。

⚡ 実際に試してみよう — タグベース無効化シミュレーション

複数のページが異なるタグでキャッシュされているとき、revalidateTagが正確に何を無効化するかを確認する。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

確認クイズ

記事詳細ページのコメントセクションのfetchのみを無効化したい場合、最も正確なツールはどれか?
💡 **タグベースの無効化**が最も正確です。コメントの fetch に `comments-42` タグを付け、コメント追加 Server Action 内で `revalidateTag('comments-42')` を呼ぶと、その記事のコメントだけを正確に無効化できます。記事本文・他の記事・サイドバーはすべてキャッシュが維持されます。`revalidatePath` は範囲が広すぎ、`no-store` はキャッシュ自体を放棄することになります。
次のおすすめ: Python 完全マスター
Revalidation戦略 — revalidatePath / revalidateTag - Next.js