Revalidation strategies in Next.js
Revalidation in Next.js updates cached pages with fresh data without triggering a full site rebuild.
Theory
TL;DR
- Think of a coffee shop with pre-brewed pots: customers always get coffee from the pot (cache), but every hour someone brews a fresh batch in the background while the old one is still being served.
- Time-based revalidation refreshes on a schedule (stale-while-revalidate); on-demand revalidation invalidates the cache the moment data changes.
- Decision rule: time-based for content that changes rarely (blogs, docs); on-demand for anything where stale data causes real problems (prices, inventory).
revalidateTaggives surgical control: one tag can cover multiple fetches across different routes.
Quick example
// app/products/page.tsx (App Router)
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // Serve cache for 1 hour, then refresh in background
});
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
// First visit: fetches live data, stores in cache
// Visits within 1 hour: served from cache (~50ms)
// First visit after 1 hour: still gets cached version,
// but triggers a background fetch for the next visitor
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}That last point is the one most developers miss. The user who triggers revalidation still gets stale data. Only the next visitor gets the fresh version.
Key difference
Time-based and on-demand revalidation solve different problems. Time-based works with stale-while-revalidate: the page is served from cache instantly, and Next.js spawns a background job to regenerate it after the TTL expires. On-demand skips the timer entirely. A webhook or server action calls revalidatePath() or revalidateTag(), and the cache is invalidated right away. No waiting for a timer to run out.
When to use
- Blog posts, docs, SEO landing pages:
revalidate: 86400(daily). A day-old article is fine. - E-commerce product catalog:
revalidate: 300(5 minutes) as a fallback, plusrevalidateTag('products')from a Shopify/Stripe webhook for immediate updates. - Live scores, exchange rates, stock prices:
cache: 'no-store'orexport const dynamic = 'force-dynamic'. Do not cache at all. - User-specific data (dashboards, profiles):
noStore()or per-request dynamic rendering. ISR does not work here. - Large sites with related content (cart + product list): tag-based revalidation with
revalidateTag('cart')so only what changed gets updated.
Comparison table
| Strategy | Trigger | Stale tolerance | Best for | Latency impact |
|---|---|---|---|---|
| Time-based (ISR) | Timer (revalidate: 3600) | Up to interval | Blogs, SEO pages | None (stale-while-revalidate) |
| On-demand path | revalidatePath('/products') | Zero after trigger | CMS content, mutations | None (background) |
| On-demand tag | revalidateTag('products') | Zero after trigger | E-commerce, granular updates | None (background) |
| Per-request dynamic | Every request | None | Dashboards, user data | High (always fetches) |
| No cache | cache: 'no-store' | None | Real-time data | High (always fetches) |
| When to use | Low-change data → time-based; urgent updates → on-demand; user data → dynamic |
How it works internally
On the first request, Next.js generates the page and stores the output (HTML + JSON) in .next/cache on disk, or in Vercel's distributed cache. Subsequent requests check whether the TTL has expired. If not, the cached response goes out immediately. If it has expired, the stale response still goes out immediately (that is stale-while-revalidate), but Next.js spawns a background Node.js worker to re-fetch data and regenerate the page. Once done, it atomically swaps the cache entry so the next request gets the fresh version.
On-demand revalidation skips the TTL check entirely. revalidatePath or revalidateTag marks specific cache entries as stale on the server. The next request to that path triggers a fresh fetch, not a scheduled one.
One edge case worth knowing: the Router Cache (in-memory cache of page shells in the browser) and the Data Cache (server-side) are separate layers. revalidatePath('/products') invalidates the Data Cache, but the Router Cache in the browser can persist for up to 30 seconds in Next.js 14. If the layout also needs to update, use revalidatePath('/', 'layout').
Common mistakes
1. Mixing revalidate with dynamic rendering triggers
// Wrong: searchParams forces dynamic rendering, revalidate is ignored
export const revalidate = 3600;
export default function Page({ searchParams }: any) {
// searchParams opts this page into dynamic rendering
// revalidate does nothing here
const query = searchParams.q;
return <Results query={query} />;
}In Next.js 14+, searchParams switches the page to dynamic rendering. The revalidate export gets ignored. Either remove revalidate and accept dynamic behavior, or add noStore() explicitly.
2. Assuming revalidation happens on a schedule when there is no traffic
fetch(url, { next: { revalidate: 3600 } });
// If no one visits the page after the TTL expires,
// the cache just sits stale indefinitely.
// There is no background cron - revalidation requires an incoming request.On low-traffic pages, add a cron job that hits the URL periodically, or switch to on-demand revalidation via webhook.
3. Router Cache vs Data Cache confusion
// After revalidatePath('/'), the Data Cache updates.
// But the Router Cache (browser-side page shell) may still be stale.
// Fix:
revalidatePath('/', 'layout'); // Propagates through all layout segments4. Calling revalidateTag on untagged fetches
// This fetch has no tag - revalidateTag will not touch it
fetch(url, { next: { revalidate: 60 } });
// Later:
revalidateTag('products'); // Does nothing for the fetch aboveAlways add { tags: ['products'] } to fetches you want to control with revalidateTag. Time-based and tag-based options are mutually exclusive per fetch call.
Real-world usage
- Vercel Commerce (Next.js Commerce): on-demand webhooks from Shopify/Stripe trigger
revalidateTag('products')on inventory changes. - TinaCMS: time-based ISR with
revalidate: 10seconds for markdown content, so edits appear almost instantly without redeploys. - Supabase + Next.js: tag-based revalidation like
revalidateTag(`user-${id}`)after authentication state changes. - Medusa.js: hybrid approach - time-based for product catalogs, on-demand for order status.
- As a comparison point: use revalidation over
dynamicrendering when the data is semi-static and traffic is high. The difference between a cached response (under 50ms) and a dynamic fetch (200-500ms+) is significant at scale.
Follow-up questions
Q: What is the difference between stale-while-revalidate in Next.js and the stale-while-revalidate HTTP Cache-Control directive?
A: In Next.js, stale-while-revalidate is a server-side behavior: Next.js serves the cached page and regenerates it in the background using a Node.js worker. The HTTP directive does the same thing but at the CDN or browser level (Cloudflare, for example). They can work together - Next.js regenerates the server cache while the CDN serves its own stale copy.
Q: How does revalidation differ between the App Router and the Pages Router?
A: The Pages Router has a single ISR entry per path with no tag support. The App Router introduces separate Data Cache and Router Cache layers, supports revalidateTag for granular invalidation across multiple routes, and lets you revalidate at the layout segment level.
Q: What happens if revalidatePath is called during a Server Action?
A: Next.js marks the path's cache entry as stale immediately. The revalidation happens after the Server Action completes, not during it. The response from the action goes out first, then the cache is purged.
Q: (Senior) In a nested layout with shared data across segments, how do you update the cart count in the header without re-fetching everything?
A: Tag the cart fetch in the layout component with { tags: ['cart'] }. After a cart mutation in a Server Action or webhook, call revalidateTag('cart'). Only the tagged fetches regenerate - the rest of the layout stays cached. This beats revalidatePath('/', 'layout'), which invalidates everything at once.
Examples
Basic: time-based revalidation for a blog
// app/blog/page.tsx
async function getPosts() {
const res = await fetch('https://cms.example.com/posts', {
next: { revalidate: 86400 } // Refresh once per day
});
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<ul>
{posts.map((post: { id: string; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}A blog post that is a day old is fine. The page loads in under 50ms from cache, and readers never notice the background regeneration.
Intermediate: on-demand revalidation with a Stripe webhook
// app/shop/products/page.tsx
async function getProducts() {
const res = await fetch('https://api.stripe.com/v1/products?limit=10', {
next: { tags: ['featured-products'] }
// No timer - only updates when the webhook fires
});
return res.json();
}
export default async function ProductsPage() {
const { data: products } = await getProducts();
return products.map((p: { id: string; name: string }) => (
<div key={p.id}>{p.name}</div>
));
}// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(request: Request) {
// In production: verify the Stripe webhook signature here
revalidateTag('featured-products');
return Response.json({ revalidated: true, timestamp: Date.now() });
}When Stripe sends a product.updated event, the webhook hits /api/revalidate, the tag is invalidated, and the next visitor to /shop/products gets fresh data. Everyone before that still gets the cached version. That is the intended behavior.
Advanced: Server Action with tag-based revalidation
// actions/posts.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.insert(posts).values({ title });
// Invalidates all fetches tagged 'posts' across all routes
revalidateTag('posts');
}// app/blog/new/page.tsx
'use client';
import { createPost } from '@/actions/posts';
export function NewPostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<button type="submit">Publish</button>
</form>
);
}After submit, revalidateTag('posts') fires and every route fetching with { tags: ['posts'] } gets fresh data on the next request. No separate API route needed - Server Actions handle the revalidation directly.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.