NextJS DOC
Part 8 of 15Next.js Revalidation Deep Dive: Time-Based and On-Demand Cache Invalidation
Next.js Revalidation Deep Dive: Time-Based and On-Demand Cache Invalidation
Introduction
Revalidation is the process of updating cached data while maintaining the performance benefits of caching. Next.js Cache Components provides two primary strategies: time-based revalidation (automatic refresh after a duration) and on-demand revalidation (manual invalidation after mutations).
This guide covers the internals of both strategies, the differences between invalidation functions, and production patterns for keeping your data fresh without sacrificing performance.
Revalidation Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS REVALIDATION ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ TIME-BASED REVALIDATION │ │
│ │ (cacheLife) │ │
│ │ │ │
│ │ Request ────────────────────────────────────────────▶ Time │ │
│ │ │ │ │
│ │ ├── stale (5m) ──┤ │ │
│ │ │ Client uses │ │ │
│ │ │ cache direct │ │ │
│ │ │ │ │ │
│ │ ├────────────────┼── revalidate (1h) ────────────┤ │ │
│ │ │ │ Background refresh │ │ │
│ │ │ │ Stale content served │ │ │
│ │ │ │ │ │ │
│ │ └────────────────┴───────────────────────────────┼── expire ──▶ │ │
│ │ │ (1d) │ │
│ │ │ Blocking │ │
│ │ refresh │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ON-DEMAND REVALIDATION │ │
│ │ │ │
│ │ ┌───────────────────┬───────────────────┬───────────────────────┐ │ │
│ │ │ updateTag │ revalidateTag │ revalidatePath │ │ │
│ │ ├───────────────────┼───────────────────┼───────────────────────┤ │ │
│ │ │ Server Actions │ Server Actions │ Server Actions │ │ │
│ │ │ ONLY │ + Route Handlers │ + Route Handlers │ │ │
│ │ ├───────────────────┼───────────────────┼───────────────────────┤ │ │
│ │ │ Immediate │ Stale-while- │ Path-based │ │ │
│ │ │ expiration │ revalidate │ invalidation │ │ │
│ │ ├───────────────────┼───────────────────┼───────────────────────┤ │ │
│ │ │ User sees fresh │ User sees stale │ Entire path │ │ │
│ │ │ data immediately │ data, bg refresh │ invalidated │ │ │
│ │ └───────────────────┴───────────────────┴───────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────┐ │ │
│ │ │ refresh │ │ │
│ │ │ Server Actions ONLY - Clears entire client router cache │ │ │
│ │ └─────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Time-Based Revalidation with cacheLife
Core Concept
cacheLife defines automatic revalidation timing using three properties:
import { cacheLife } from 'next/cache'
async function getProducts() {
'use cache'
cacheLife('hours') // Use preset profile
return db.products.findMany()
}
async function getCustomData() {
'use cache'
cacheLife({
stale: 3600, // 1 hour: client uses cache without server check
revalidate: 7200, // 2 hours: background refresh triggered
expire: 86400, // 1 day: forced synchronous refresh
})
return fetchData()
}
Preset Profiles
| Profile | Use Case | stale | revalidate | expire |
|---|---|---|---|---|
seconds | Real-time data (stocks, live scores) | 0 | 1s | 60s |
minutes | Frequently updated (social feeds) | 5m | 1m | 1h |
hours | Multiple daily updates (inventory) | 5m | 1h | 1d |
days | Daily updates (blog posts) | 5m | 1d | 1w |
weeks | Weekly updates (podcasts) | 5m | 1w | 30d |
max | Stable content (legal pages) | 5m | 30d | ~indefinite |
Revalidation Timeline
┌─────────────────────────────────────────────────────────────────────────────┐
│ CACHE LIFECYCLE WITH 'hours' PROFILE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ T+0 (Initial) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Request → Cache MISS → Fetch data → Store in cache → Return │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ T+2min (Within stale window: 5 minutes) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Request → CLIENT cache hit → Return immediately │ │
│ │ (No server request at all) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ T+30min (Within revalidate window: 1 hour) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Request → SERVER cache hit → Return cached data │ │
│ │ (Data is "fresh" according to revalidate setting) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ T+90min (After revalidate, within expire: 1 day) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Request → Return STALE data immediately │ │
│ │ → Trigger BACKGROUND refresh │ │
│ │ → Update cache with fresh data │ │
│ │ (Next request gets fresh data) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ T+25hr (After expire: 1 day, no traffic) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Request → Cache EXPIRED │ │
│ │ → BLOCKING refresh (user waits) │ │
│ │ → Fetch data → Store in cache → Return │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Short-Lived Caches and Prerendering
Caches with very short lifetimes (seconds profile, revalidate: 0, or expire < 5 minutes) are excluded from prerendering and become dynamic holes:
// This becomes a dynamic hole - NOT prerendered
async function LiveScore() {
'use cache'
cacheLife('seconds') // Too short for prerendering
return fetchLiveScore()
}
// Must wrap in Suspense for streaming
export default function Page() {
return (
<Suspense fallback={<ScoreSkeleton />}>
<LiveScore />
</Suspense>
)
}
On-Demand Revalidation Functions
Decision Tree
┌─────────────────────────────────────────────────────────────────────────────┐
│ CHOOSING THE RIGHT REVALIDATION FUNCTION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Is this a Server Action? │
│ │ │
│ ├── YES ──▶ Does user need to see their change IMMEDIATELY? │
│ │ │ │
│ │ ├── YES ──▶ updateTag() + redirect() │
│ │ │ (Read-your-own-writes) │
│ │ │ │
│ │ └── NO ──▶ revalidateTag('tag', 'max') │
│ │ (Background refresh OK) │
│ │ │
│ └── NO (Route Handler, Webhook) ──▶ revalidateTag('tag', 'max') │
│ or revalidatePath('/path') │
│ │
│ Do you know which tags are affected? │
│ │ │
│ ├── YES ──▶ Use updateTag/revalidateTag (more precise) │
│ │ │
│ └── NO ──▶ Use revalidatePath (invalidates entire path) │
│ │
│ Do you need to clear ALL client cache? │
│ │ │
│ ├── YES ──▶ refresh() (Server Action only) │
│ │ Use for: logout, role change, major state change │
│ │ │
│ └── NO ──▶ Use tag/path-based revalidation │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
updateTag - Immediate Invalidation
Use when: User must see their change immediately after mutation.
Constraints: Server Actions only (not Route Handlers).
Behavior: Expires cache immediately. Next request waits for fresh data.
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
})
// Expire all caches tagged 'posts'
updateTag('posts')
// Expire the specific post cache (for edit scenarios)
updateTag(`post-${post.id}`)
// Redirect - user sees fresh data, NOT stale cache
redirect(`/posts/${post.id}`)
}
revalidateTag - Stale-While-Revalidate
Use when: Background refresh is acceptable, slight delay is OK.
Constraints: Works in Server Actions AND Route Handlers.
Behavior: Marks cache as stale. Stale content served while fresh data fetches in background.
'use server'
import { revalidateTag } from 'next/cache'
// Server Action - background refresh
export async function publishPost(postId: string) {
await db.posts.update({
where: { id: postId },
data: { status: 'published' },
})
// 'max' profile: longest stale window, background refresh
revalidateTag('posts', 'max')
revalidateTag(`post-${postId}`, 'max')
}
// Route Handler - webhook from CMS
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const { secret, tag } = await request.json()
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 })
}
// Revalidate with stale-while-revalidate
revalidateTag(tag, 'max')
return Response.json({ revalidated: true, tag, now: Date.now() })
}
Immediate expiration in Route Handlers:
For webhooks requiring immediate invalidation:
// When external system needs data expired NOW
revalidateTag(tag, { expire: 0 })
revalidatePath - Path-Based Invalidation
Use when: You don't know which tags are affected, or want to invalidate an entire route.
Constraints: Works in Server Actions and Route Handlers.
import { revalidatePath } from 'next/cache'
// Revalidate specific page
revalidatePath('/blog/post-1')
// Revalidate all pages matching pattern (requires type)
revalidatePath('/blog/[slug]', 'page')
// Revalidate layout and all pages beneath it
revalidatePath('/blog/[slug]', 'layout')
// Revalidate EVERYTHING (nuclear option)
revalidatePath('/', 'layout')
Path Types Explained
┌─────────────────────────────────────────────────────────────────────────────┐
│ revalidatePath TYPE PARAMETER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ revalidatePath('/blog/hello-world') │
│ └─▶ Literal path - revalidates single specific page │
│ No type parameter needed │
│ │
│ revalidatePath('/blog/[slug]', 'page') │
│ └─▶ Dynamic segment with 'page' type │
│ Revalidates: /blog/post-1, /blog/post-2, etc. │
│ Does NOT revalidate: /blog/post-1/comments │
│ │
│ revalidatePath('/blog/[slug]', 'layout') │
│ └─▶ Dynamic segment with 'layout' type │
│ Revalidates layout.tsx at /blog/[slug]/ │
│ ALSO revalidates ALL pages beneath it: │
│ - /blog/post-1 │
│ - /blog/post-1/comments │
│ - /blog/post-1/comments/[id] │
│ │
│ revalidatePath('/', 'layout') │
│ └─▶ Root layout invalidation │
│ Purges client cache entirely │
│ Revalidates ALL cached data on next visit │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
refresh - Clear Entire Client Cache
Use when: Major state change requires complete cache purge (logout, role change).
Constraints: Server Actions only.
'use server'
import { refresh } from 'next/cache'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function logout() {
// Clear session
const cookieStore = await cookies()
cookieStore.delete('session')
cookieStore.delete('userId')
// Clear ALL client-side cached data
// User will see fresh data on every subsequent navigation
refresh()
redirect('/login')
}
export async function changeUserRole(userId: string, newRole: string) {
await db.users.update({
where: { id: userId },
data: { role: newRole },
})
// Role affects what data user can see
// Clear everything to ensure correct permissions
refresh()
}
Comparison Matrix
| Function | Context | Behavior | Use Case |
|---|---|---|---|
updateTag(tag) | Server Actions only | Immediate expiration, next request blocks | Read-your-own-writes (create, update) |
revalidateTag(tag, 'max') | Server Actions + Route Handlers | Stale-while-revalidate | Background refresh, CMS webhooks |
revalidateTag(tag, { expire: 0 }) | Server Actions + Route Handlers | Immediate expiration | Webhook requiring instant invalidation |
revalidatePath(path) | Server Actions + Route Handlers | Path-based invalidation | When tags are unknown |
revalidatePath(path, 'page') | Server Actions + Route Handlers | Pattern match all pages | Dynamic route invalidation |
revalidatePath(path, 'layout') | Server Actions + Route Handlers | Layout + all nested pages | Hierarchical invalidation |
refresh() | Server Actions only | Clear entire client cache | Logout, role change |
Production Patterns
Pattern 1: CRUD Operations with Immediate Feedback
// lib/posts/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const PostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
categoryId: z.string().uuid(),
})
type ActionState = {
success: boolean
message?: string
errors?: Record<string, string[]>
}
export async function createPost(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const session = await auth()
if (!session?.user) {
return { success: false, message: 'Unauthorized' }
}
const validated = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
categoryId: formData.get('categoryId'),
})
if (!validated.success) {
return {
success: false,
errors: validated.error.flatten().fieldErrors,
}
}
const { categoryId, ...data } = validated.data
const post = await db.posts.create({
data: {
...data,
categoryId,
authorId: session.user.id,
},
})
// Invalidate ALL affected caches
updateTag('posts') // All posts lists
updateTag(`category-${categoryId}`) // Category listing
updateTag(`user-${session.user.id}-posts`) // User's posts
redirect(`/posts/${post.id}`)
}
export async function updatePost(
postId: string,
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const session = await auth()
if (!session?.user) {
return { success: false, message: 'Unauthorized' }
}
// Fetch existing post for authorization and category tracking
const existingPost = await db.posts.findUnique({
where: { id: postId },
})
if (!existingPost) {
return { success: false, message: 'Post not found' }
}
if (existingPost.authorId !== session.user.id) {
return { success: false, message: 'Not authorized' }
}
const validated = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
categoryId: formData.get('categoryId'),
})
if (!validated.success) {
return {
success: false,
errors: validated.error.flatten().fieldErrors,
}
}
const { categoryId, ...data } = validated.data
await db.posts.update({
where: { id: postId },
data: { ...data, categoryId },
})
// Invalidate affected caches
updateTag(`post-${postId}`) // The specific post
updateTag('posts') // All posts lists
// If category changed, invalidate both
if (existingPost.categoryId !== categoryId) {
updateTag(`category-${existingPost.categoryId}`)
updateTag(`category-${categoryId}`)
}
return { success: true, message: 'Post updated' }
}
export async function deletePost(postId: string): Promise<ActionState> {
const session = await auth()
if (!session?.user) {
return { success: false, message: 'Unauthorized' }
}
const post = await db.posts.findUnique({
where: { id: postId },
})
if (!post) {
return { success: false, message: 'Post not found' }
}
if (post.authorId !== session.user.id) {
return { success: false, message: 'Not authorized' }
}
await db.posts.delete({ where: { id: postId } })
// Invalidate caches
updateTag(`post-${postId}`)
updateTag('posts')
updateTag(`category-${post.categoryId}`)
updateTag(`user-${session.user.id}-posts`)
redirect('/posts')
}
Pattern 2: CMS Webhook Handler
// app/api/cms/webhook/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
import crypto from 'crypto'
function verifySignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSig = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSig)
)
}
type CMSEvent = {
event: 'publish' | 'unpublish' | 'update' | 'delete'
contentType: string
entryId: string
locale?: string
}
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-cms-signature')
const payload = await request.text()
if (!signature || !verifySignature(payload, signature, process.env.CMS_WEBHOOK_SECRET!)) {
return Response.json({ error: 'Invalid signature' }, { status: 401 })
}
const event: CMSEvent = JSON.parse(payload)
const tags: string[] = []
// Build list of tags to revalidate based on content type
switch (event.contentType) {
case 'blogPost':
tags.push('blog-posts', `blog-post-${event.entryId}`)
break
case 'product':
tags.push('products', `product-${event.entryId}`)
break
case 'category':
tags.push('categories', `category-${event.entryId}`, 'products')
break
case 'siteConfig':
// Site-wide config affects everything
tags.push('site-config', 'navigation', 'footer')
break
default:
tags.push(`${event.contentType}-${event.entryId}`)
}
// Add locale-specific tags if applicable
if (event.locale) {
tags.push(...tags.map(tag => `${tag}-${event.locale}`))
}
// Revalidate all tags with stale-while-revalidate
for (const tag of tags) {
revalidateTag(tag, 'max')
}
console.log(`[CMS Webhook] Revalidated tags: ${tags.join(', ')}`)
return Response.json({
revalidated: true,
tags,
timestamp: Date.now(),
})
}
Pattern 3: Hierarchical Data with Cascading Invalidation
// lib/cache/invalidation.ts
import { updateTag } from 'next/cache'
type EntityType = 'workspace' | 'project' | 'task' | 'comment'
const ENTITY_HIERARCHY: Record<EntityType, EntityType[]> = {
workspace: ['project', 'task', 'comment'], // Workspace changes affect all below
project: ['task', 'comment'], // Project changes affect tasks and comments
task: ['comment'], // Task changes affect comments
comment: [], // Comments are leaf nodes
}
export function invalidateEntity(
type: EntityType,
id: string,
options: { cascade?: boolean } = {}
): void {
// Always invalidate the entity itself
updateTag(`${type}-${id}`)
// Invalidate list caches
updateTag(`${type}s`)
if (options.cascade) {
// Invalidate child entity types
const children = ENTITY_HIERARCHY[type]
for (const childType of children) {
updateTag(`${type}-${id}-${childType}s`) // e.g., workspace-123-projects
}
}
}
// Usage in Server Actions
export async function deleteWorkspace(workspaceId: string) {
await db.workspaces.delete({ where: { id: workspaceId } })
// Cascade invalidation - all projects, tasks, comments under this workspace
invalidateEntity('workspace', workspaceId, { cascade: true })
}
export async function updateProject(projectId: string, data: ProjectData) {
await db.projects.update({ where: { id: projectId }, data })
// Just invalidate project, not children (task data unchanged)
invalidateEntity('project', projectId, { cascade: false })
}
Pattern 4: Optimistic Updates with Fallback Revalidation
// app/posts/[id]/actions.ts
'use server'
import { updateTag } from 'next/cache'
export async function likePost(postId: string): Promise<{
success: boolean
newLikeCount?: number
error?: string
}> {
const session = await auth()
if (!session?.user) {
return { success: false, error: 'Unauthorized' }
}
try {
// Toggle like
const existingLike = await db.likes.findUnique({
where: {
postId_userId: { postId, userId: session.user.id },
},
})
if (existingLike) {
await db.likes.delete({
where: { id: existingLike.id },
})
} else {
await db.likes.create({
data: { postId, userId: session.user.id },
})
}
// Get updated count
const newLikeCount = await db.likes.count({
where: { postId },
})
// Invalidate post cache
updateTag(`post-${postId}`)
return { success: true, newLikeCount }
} catch (error) {
console.error('Failed to update like:', error)
return { success: false, error: 'Failed to update' }
}
}
// components/LikeButton.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { likePost } from '../actions'
interface LikeButtonProps {
postId: string
initialLikeCount: number
isLiked: boolean
}
export function LikeButton({ postId, initialLikeCount, isLiked }: LikeButtonProps) {
const [isPending, startTransition] = useTransition()
const [optimisticState, setOptimistic] = useOptimistic(
{ count: initialLikeCount, liked: isLiked },
(state, newLiked: boolean) => ({
count: newLiked ? state.count + 1 : state.count - 1,
liked: newLiked,
})
)
async function handleClick() {
const newLikedState = !optimisticState.liked
startTransition(async () => {
// Optimistic update
setOptimistic(newLikedState)
// Actual server call
const result = await likePost(postId)
if (!result.success) {
// Server call failed - optimistic update will be reverted
// when component re-renders with server state
console.error(result.error)
}
})
}
return (
<button
onClick={handleClick}
disabled={isPending}
className={optimisticState.liked ? 'liked' : ''}
>
{optimisticState.liked ? '❤️' : '🤍'} {optimisticState.count}
</button>
)
}
Pattern 5: Multi-Region Cache Invalidation
// lib/cache/distributed.ts
import { updateTag, revalidateTag } from 'next/cache'
interface InvalidationEvent {
tags: string[]
immediate: boolean
regions?: string[]
}
export async function invalidateGlobal(event: InvalidationEvent): Promise<void> {
const { tags, immediate, regions } = event
// Local invalidation
for (const tag of tags) {
if (immediate) {
updateTag(tag)
} else {
revalidateTag(tag, 'max')
}
}
// Propagate to other regions via queue/webhook
if (regions && regions.length > 0) {
await propagateToRegions(event)
}
}
async function propagateToRegions(event: InvalidationEvent): Promise<void> {
const { regions = [], tags, immediate } = event
const promises = regions.map(async (region) => {
const endpoint = getRegionEndpoint(region)
try {
const response = await fetch(`${endpoint}/api/cache/invalidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Secret': process.env.INTERNAL_SECRET!,
},
body: JSON.stringify({ tags, immediate }),
})
if (!response.ok) {
console.error(`Failed to invalidate in region ${region}:`, await response.text())
}
} catch (error) {
console.error(`Network error invalidating in region ${region}:`, error)
}
})
await Promise.allSettled(promises)
}
function getRegionEndpoint(region: string): string {
const regionEndpoints: Record<string, string> = {
'us-east': 'https://us-east.example.com',
'eu-west': 'https://eu-west.example.com',
'ap-south': 'https://ap-south.example.com',
}
return regionEndpoints[region] || ''
}
Cache Tagging Strategies
Naming Conventions
// Entity tags
cacheTag('products') // All products
cacheTag(`product-${id}`) // Specific product
cacheTag(`category-${categoryId}`) // Category listing
// Relationship tags
cacheTag(`user-${userId}-posts`) // User's posts
cacheTag(`product-${productId}-reviews`) // Product reviews
// Locale/tenant tags
cacheTag(`products-en`) // English products
cacheTag(`tenant-${tenantId}-products`) // Tenant-specific
// Composite tags for precision
cacheTag('products', `category-${cat}`, `brand-${brand}`)
Tag Granularity Trade-offs
┌─────────────────────────────────────────────────────────────────────────────┐
│ TAG GRANULARITY SPECTRUM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ COARSE FINE │
│ (Few tags, broad invalidation) (Many tags, precise invalidation) │
│ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ 'products' 'products-electronics' 'product-123' │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ Single change Category-level Individual product │ │
│ │ invalidates ALL precision precision │ │
│ │ │ │
│ │ Pros: Pros: Pros: │ │
│ │ - Simple - Balanced - Minimal over- │ │
│ │ - Few tags - Good precision invalidation │ │
│ │ - Easy to reason - Manageable - Cache efficient │ │
│ │ │ │
│ │ Cons: Cons: Cons: │ │
│ │ - Over-invalidation - More tags - Many tags │ │
│ │ - Cache misses - More complex - Complex tracking │ │
│ │ invalidation - Potential for │ │
│ │ missing tags │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ RECOMMENDATION: Start with medium granularity (category-level) and │
│ add finer tags only where cache efficiency is critical │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Common Pitfalls
Pitfall 1: Using updateTag in Route Handlers
// ❌ WRONG - updateTag only works in Server Actions
export async function POST(request: NextRequest) {
const { postId } = await request.json()
await db.posts.delete({ where: { id: postId } })
updateTag('posts') // ERROR: Cannot use updateTag in Route Handler
return Response.json({ success: true })
}
// ✅ CORRECT - Use revalidateTag in Route Handlers
export async function POST(request: NextRequest) {
const { postId } = await request.json()
await db.posts.delete({ where: { id: postId } })
// Use 'max' for stale-while-revalidate
revalidateTag('posts', 'max')
// Or use { expire: 0 } for immediate expiration
revalidateTag('posts', { expire: 0 })
return Response.json({ success: true })
}
Pitfall 2: Missing Tags in Cached Functions
// ❌ WRONG - No tag means no way to invalidate
async function getProducts() {
'use cache'
cacheLife('hours')
return db.products.findMany()
}
// ✅ CORRECT - Always tag cached data
async function getProducts() {
'use cache'
cacheTag('products')
cacheLife('hours')
return db.products.findMany()
}
Pitfall 3: Over-Invalidation with revalidatePath
// ❌ OVERLY BROAD - Invalidates everything
export async function updatePost(postId: string) {
await db.posts.update(...)
revalidatePath('/', 'layout') // Nuclear option!
}
// ✅ PRECISE - Only invalidate what changed
export async function updatePost(postId: string) {
await db.posts.update(...)
updateTag(`post-${postId}`)
updateTag('posts')
}
Pitfall 4: Not Handling Category/Relationship Changes
// ❌ INCOMPLETE - Doesn't handle category change
export async function moveProduct(productId: string, newCategoryId: string) {
await db.products.update({
where: { id: productId },
data: { categoryId: newCategoryId },
})
updateTag(`product-${productId}`) // Missing category invalidation!
}
// ✅ COMPLETE - Invalidate old and new categories
export async function moveProduct(productId: string, newCategoryId: string) {
const product = await db.products.findUnique({ where: { id: productId } })
const oldCategoryId = product?.categoryId
await db.products.update({
where: { id: productId },
data: { categoryId: newCategoryId },
})
updateTag(`product-${productId}`)
if (oldCategoryId) updateTag(`category-${oldCategoryId}`)
updateTag(`category-${newCategoryId}`)
updateTag('products')
}
Key Takeaways
- Time-based vs On-demand: Use
cacheLifefor automatic refresh; useupdateTag/revalidateTagafter mutations - updateTag for Read-Your-Own-Writes: User sees their change immediately; Server Actions only
- revalidateTag for Background Refresh: Stale content served while fetching; works in Route Handlers
- Always use 'max' profile:
revalidateTag(tag, 'max')for proper stale-while-revalidate semantics - revalidatePath as Fallback: Use when tags are unknown; less precise than tag-based
- refresh for Global Clear: Clear entire client cache on logout or role change
- Tag Everything: Cached data without tags cannot be invalidated on-demand
- Cascade Thoughtfully: Consider what data depends on what when invalidating
- Short-lived = Dynamic:
secondsprofile or <5min expire creates dynamic holes, not prerendered content - Verify in Route Handlers: For webhooks needing immediate invalidation, use
revalidateTag(tag, { expire: 0 })
References
What did you think?
Related Posts
April 4, 202697 min
Next.js Proxy Deep Dive: Edge-First Request Interception
April 4, 2026164 min
Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards
April 4, 202691 min