C
Next.js/SEO/Lesson 15

Revalidation Strategies — revalidatePath · revalidateTag · time-based

30 min·theory
This chapter
2/2
TypeScript

Revalidation Strategies — revalidatePath · revalidateTag · time-based

💡 Why Learn This? — The #1 cause of the 'I published a post but it's not showing' bug

🎯 Next.js fetch is cached by default. If you don't invalidate after a mutation, users will see stale data.
💼 Four tools — `revalidatePath`, `revalidateTag`, `revalidate: N`, and `cache: 'no-store'` — each serve different use cases.
Used incorrectly, you end up at one of two extremes: zero caching benefit (every-request SSR) or permanently stale data (indefinite cache).
🔗 In Server Actions, webhooks, and admin pages, the exact moment of invalidation within the mutation flow is critical.
📈 **One-line rule**: attach `tags` to your data fetch, and call `revalidateTag(...)` at the end of the mutation Server Action — that covers 90% of cases.
🏢 실무에서는
Writing a blog post → invalidate both the list page and the detail page. Adding a comment → invalidate only the comment section. An admin updates a product price → invalidate that product card, search results, and the category page. Each scenario calls for a different tool.

4 Tools — Usage Matrix

1. Overview of 4 Tools

ToolLocationEffect
revalidate: N (fetch option)Page / ComponentCaches at build time, background regeneration after N seconds (ISR)
cache: 'no-store'Page / ComponentNo caching, SSR on every request
revalidatePath('/x')Server Action / Route HandlerInvalidates all fetch caches for the /x page
revalidateTag('tag')Server Action / Route HandlerPrecisely invalidates only fetches tagged with 'tag'

2. The tags System — The Most Powerful Pattern

ts
// 📁 app/posts/page.tsx — attach tags to fetch
const posts = await fetch('https://api/posts', {
  next: { tags: ['posts'] },
}).then(r => r.json());
ts
// 📁 app/posts/actions.ts — invalidate that tag after mutation
'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');
  // Invalidates all fetch caches tagged 'posts' (regardless of page or component)
}

More precise than revalidatePath — attach the same tag to fetches across multiple pages and they are all invalidated at once.

3. Hierarchical Tag Strategy

ts
// Post list page
fetch('/api/posts', { next: { tags: ['posts'] } });

// Post detail page
fetch(`/api/posts/${id}`, { next: { tags: ['posts', `post-${id}`] } });

// Comments
fetch(`/api/posts/${id}/comments`, { next: { tags: [`post-${id}`, `comments-${id}`] } });
ts
// Add comment → invalidate only that post's comments (leave post list intact)
revalidateTag(`comments-${id}`);

// Edit post → invalidate both the post and the list
revalidateTag('posts');       // list
revalidateTag(`post-${id}`);  // detail

// Delete post → invalidate all post-related caches
revalidateTag('posts');

4. revalidatePath — Path-level Invalidation

ts
revalidatePath('/posts');                    // All fetches on the /posts page
revalidatePath('/posts/[slug]', 'page');     // All dynamic path variants
revalidatePath('/blog', 'layout');           // Includes layout (all children)

Not as precise as tags, but useful for migrations and quick fixes.

5. Time-based Revalidation (ISR)

ts
fetch('https://api/posts', {
  next: { revalidate: 60 },  // Cache for 60 seconds, then background regeneration
});

// Page level — sets the maximum revalidate across all fetches on the page
export const revalidate = 60;

Ideal for data that rarely changes or has no mutations (blogs, docs, landing pages).

6. Summary — Decision Flow

code
Frequent mutations?
├─ Yes → tags + revalidateTag (precise invalidation)
└─ No
   └─ Does data expire over time?
      ├─ Yes → revalidate: N (ISR)
      └─ No → default force-cache (permanent cache)

User-specific data?
└─ cache: 'no-store' (SSR every time)
💻 🅰️ Without tags — using revalidatePath only
// ❌ Only revalidatePath — must know all related paths

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

// 📁 app/sidebar/RecentPosts.tsx (also used in other components)
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 } });

  // Invalidate all related paths one by one
  revalidatePath('/posts');           // Post list
  revalidatePath('/');                // Home has RecentPosts
  revalidatePath('/sidebar', 'layout'); // Sidebar has RecentPosts
  // If even one is missed, that page will have old data
}

// Disadvantages:
// - Mutation code must know all pages that use the data
// - Adding a new page requires modifying mutation code
// - If global components like sidebar/footer use related data, the entire layout is invalidated
💻 🅱️ tags System — Data-level Invalidation
// ✅ tags + revalidateTag — Invalidate by data unit

// 📁 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 (anywhere)
async function fetchRecentPosts() {
  return fetch('https://api/posts?limit=5', {
    next: { tags: ['posts'] },
  }).then(r => r.json());
}

// 📁 app/posts/actions.ts — Mutation code only needs to know tags
'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');
  // All fetches with 'posts' tag (list, home RecentPosts, sidebar, etc.) are automatically invalidated
}

export async function updatePost(id: number, formData: FormData) {
  await db.post.update({
    where: { id },
    data: { title: formData.get('title') as string },
  });
  revalidateTag('posts');           // List (title changed)
  revalidateTag(`post-${id}`);      // Details + comments (comments also have post-{id} tag)
}

export async function addComment(postId: number, formData: FormData) {
  await db.comment.create({
    data: { postId, text: formData.get('text') as string },
  });
  revalidateTag(`comments-${postId}`); // Only comments for that post (overall post list, other posts remain)
}

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

// Advantages:
// - Mutation code doesn't need to know where data is used
// - Adding a new page with the same fetch doesn't require mutation modification
// - Higher accuracy (only necessary tags, unrelated page caches remain)
// - On-demand possible — can call revalidateTag upon receiving a webhook

💡 💡 Revalidation Best Practices — Top 5

1. Default policy — tags on fetch, revalidateTag on mutation
This single pattern covers 90% of cases.

2. revalidatePath is a fallback
Use it only when tags are tricky or you need a quick migration. Lower precision.

3. cache: 'no-store' only for truly user-specific data
Overusing it turns every page into SSR — higher server load and slower responses. Instead, isolate only that part as a Client Component.

4. revalidate: N heuristics

code
Fewer than once per minute     → revalidate: 60
A few times per hour           → revalidate: 300~600
Rarely changes                 → force-cache (default) + tags

5. On-demand pattern — external invalidation via webhook

ts
// app/api/revalidate/route.ts — called from external systems
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 });
}

When a CMS or external system updates data, it sends a notification to trigger automatic invalidation.

⚡ Try It Yourself — Tag-based Invalidation Simulation

When multiple pages are cached under different tags, see exactly what revalidateTag invalidates.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Your Understanding

Which tool is the most precise when you want to invalidate only the comments section fetch on a post detail page?
💡 **Tag-based invalidation** is the most precise approach. Attach the `comments-42` tag to the comment fetch, then call `revalidateTag('comments-42')` inside the add-comment Server Action — this invalidates only that post's comments, while the post body, other posts, and the sidebar all remain cached. `revalidatePath` is too broad, and `no-store` abandons caching altogether.
Revalidation Strategy — revalidatePath / revalidateTag - Next.js