How Revalidation Works with "use cache"
This article is part of a series on Next.js Cache Components. See all articles: Cache Components in Next.js · Data-Level vs. UI-Level Caching · How Revalidation Works with "use cache" · Migrating to Cache Components
Caching is only useful if you can control when cached data gets replaced. Next.js Cache Components provide three functions for this: revalidateTag, updateTag, and revalidatePath. They have different behaviors and choosing the wrong one leads to either stale content or unnecessary cache misses.
revalidateTag: stale-while-revalidate
revalidateTag marks cached data as stale without removing it. The second argument is a profile that was introduced in Next.js 16 and controls revalidation behavior:
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-webhook-secret')
if (secret !== process.env.REVALIDATION_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
const payload = await request.json()
const contentId = parseContentId(payload)
revalidateTag(contentId, 'max')
return new Response('OK')
}
With profile="max", stale content can continue to be served while fresh content generates in the background. If the regeneration fails, for example because the CMS API is temporarily down, the last successfully generated content continues to be served. Next.js retries on the next request. Your site stays up with slightly stale data instead of showing an error.
Almost every Next.js project I've worked on uses revalidateTag via CMS webhooks. CMS content is ideal for this pattern: the APIs tend to be slow (50-200ms per request is common), the content rarely changes, and when it does change, the CMS tells you exactly what changed. A webhook handler like the one above is all you need. The structure is the same regardless of which CMS you use: receive the webhook, verify the signature, parse the content ID, call revalidateTag. For webhooks that need immediate cache expiration instead of stale-while-revalidate, pass { expire: 0 } as the profile.
updateTag: immediate freshness
updateTag is designed for Server Actions and forces the next request to serve fresh data. There is no stale intermediate and no background regeneration.
'use server'
import { updateTag } from 'next/cache'
export async function postGuestbookEntryAction(formData: FormData) {
const message = formData.get('message') as string
await db.guestbook.create({ data: { message } })
updateTag('guestbook')
}
Back to the Die Ärzte guestbook from the first article in this series: when a fan posts a new entry, they expect to see it on the page right away. Stale-while-revalidate would show them the old entry list after their own submission. updateTag ensures the very next render reflects the change.
The same applies to any user-facing mutation: updating a shopping cart, toggling a setting, publishing a post from an admin panel. The user triggered the action and is looking at the screen waiting for the result.
revalidatePath: path-based invalidation
revalidatePath invalidates a specific route path rather than a tag. It's the most direct approach when you know exactly which page needs to update.
revalidatePath('/blog') // revalidate a specific page
revalidatePath('/blog', 'layout') // revalidate a layout and all child pages
In most cases, tag-based revalidation with revalidateTag is more precise. revalidatePath can over-invalidate because it doesn't know which data on that page actually changed. But it's useful when you want to force a specific page to regenerate regardless of which tags it uses, for example after a deployment or a data migration.
When to use which
The choice maps to who triggers the change and what they expect:
revalidateTag is for external data changes. A CMS editor publishes an update or a backend process modifies a database record. The user visiting your site didn't cause the change and won't notice a brief stale window.
updateTag is for user-triggered mutations. A fan posted a guestbook entry, a customer added something to their cart, an admin published a page. They're looking at the screen and expect to see the result immediately.
revalidatePath is for path-specific invalidation when you know the exact route but don't have or need cache tags.
Narrow tags: tag by entity
The key to effective cache invalidation is tagging at the right granularity. Tag by entity, not by content type.
import { cacheTag, cacheLife } from 'next/cache'
export async function getProduct(id: string) {
'use cache'
cacheTag(`product-${id}`)
cacheLife('max')
return db.products.findUnique({ where: { id } })
}
When product #4711 changes, you call revalidateTag('product-4711', 'max'). Only that product's cache entry gets invalidated across every page that uses it: the product detail page, the category listing, the search results, the homepage featured section. Everything else stays cached.
Compare that to a broad tag like cacheTag('products'). Calling revalidateTag('products', 'max') would invalidate every product in your catalog at once. For a shop with thousands of products, that's thousands of cache entries that need to regenerate on the next request.
Broad tags still have their place (the bademeister.com guestbook uses cacheTag('guestbook') because a new entry shifts all pages). But when you can scope to a single entity, do it.
You can also combine tags on the same function for different invalidation strategies:
export async function getProduct(id: string) {
'use cache'
cacheTag(`product-${id}`) // invalidate one product
cacheTag('enterprise-acme') // invalidate all data for one customer
cacheLife('max')
return db.products.findUnique({ where: { id } })
}
Database subscriptions
CMS webhooks work for content that changes through an editor interface. But what about data that changes through application logic, outside of a Server Action context?
Some databases support subscriptions that notify your application when data changes. Rhys Sullivan from Vercel demonstrated a Convex integration with "use cache" and revalidateTag that sets up a subscription per query. When data changes in Convex, a callback fires that calls revalidateTag with the affected entity's tag. The result is server-rendered content that stays fresh without polling or arbitrary cache expiration.
cacheLife: the safety net
Even with tag-based invalidation in place, always set a cacheLife. It's your fallback for when a webhook fails to deliver, a subscription drops, or you forget to call revalidateTag after a data migration.
Choose a profile based on how frequently your content changes:
'seconds': real-time data (stock prices, live scores)'minutes': frequently updated (social feeds, news)'hours': multiple daily updates (product inventory, weather)'days': daily updates (blog posts, articles)'weeks': weekly updates (podcasts, newsletters)'max': rarely changes (legal pages, archived content)
For content with reliable webhook-based invalidation, cacheLife('max') is a good default. The content stays cached until you explicitly invalidate it, but even if the webhook never fires, 'max' eventually triggers a background revalidation. For content where you're less confident about the invalidation pipeline, cacheLife('days') or cacheLife('hours') gives you a tighter safety net. See the cacheLife API reference for the exact timing values behind each preset.
The combination of narrow tags and a cacheLife fallback gives you the best of both approaches: content stays cached until you explicitly invalidate it, but stale data has a bounded lifetime even if the invalidation never fires.