Integrating CMS Webhooks with `revalidateTag` — A Practical Guide to Instant On-Demand Cache Invalidation in Next.js
This article is Part 2 of the Next.js Caching Strategy Series and assumes a basic understanding of the Next.js App Router and Route Handlers.
Have you ever edited content, hit the publish button, and then waited tens of minutes for the changes to appear on the site? As long as you use time-based caching like revalidate: 60, readers will continue to see stale content until the TTL expires — and during low-traffic periods, revalidation may not even trigger after expiry. That's because cache revalidation isn't actively initiated by the server; it's only triggered when the next request comes in.
Time-based (revalidate: N) |
On-demand (revalidateTag) |
|
|---|---|---|
| Revalidation timing | On next request after TTL expires | Immediately on event (webhook) |
| Invalidation scope | Per route | Per tag (fine-grained selection) |
| During low traffic | May stay stale even after expiry | Marked stale at event time |
| Unnecessary rebuilds | Occur periodically at all times | Only changed content is refreshed |
The Next.js App Router provides on-demand cache invalidation via a combination of the 'use cache' directive, cacheTag(), and revalidateTag() to solve this problem. The moment content is saved in a CMS, a webhook fires, Next.js immediately marks the relevant cache as stale, and the next visitor receives fresh data. This article walks through, step by step, how to build tag-based on-demand cache invalidation integrated with headless CMSes like Sanity and Contentful.
Core Concepts
How the Three APIs Work Together
On-demand invalidation in Next.js 15 relies on three components working in concert.
| API | Role |
|---|---|
'use cache' directive |
A compiler directive that applies caching at the function, component, or file level |
cacheTag(tag) |
Attaches an identifier tag to a cache entry |
revalidateTag(tag) |
Invalidates all cache entries carrying the given tag |
The basic pattern is as follows:
import { cacheTag, cacheLife } from 'next/cache';
async function getPost(slug: string) {
'use cache';
cacheTag('post', `post-${slug}`);
// 'max' profile: long-lived cache with up to 1 year stale lifetime (Next.js default profile)
cacheLife('max');
return db.post.findUnique({ where: { slug } });
}
cacheTagand the numericrevalidateoption cannot be used together. When switching to a tag-based strategy, it is recommended to replace existingrevalidate: Nsettings withcacheLife().
End-to-End Flow Diagram
[CMS edit saved]
↓ Webhook HTTP POST
[Next.js Route Handler]
↓ revalidateTag() called
[Matching tag cache → marked stale]
↓ Next visitor request
[Fetch latest data in background → cache refreshed]
(On webhook miss) ·········→ [Auto-refresh after fallback TTL expires]A stale-marked cache continues serving the previous data until revalidation completes (stale-while-revalidate). The refresh happens silently in the background with no degradation to the user experience, and even if a webhook is missed, the fallback TTL acts as a safety net (the fallback TTL pattern is covered in detail in the examples below).
revalidateTag vs updateTag
updateTag was newly introduced in Next.js 15. The two APIs differ in their invalidation behavior (see the Next.js official docs).
| API | Behavior | Suitable scenarios |
|---|---|---|
revalidateTag |
stale-while-revalidate (background refresh) | General CMS edits — blog posts, marketing pages, etc. |
updateTag |
Immediate expiry (waits for new data on next request) | Cases requiring immediacy — payment status, inventory count, etc. |
Practical Application
Sanity Integration
Sanity can selectively fire webhooks only when a specific _type changes via GROQ-powered Webhooks. Using the parseBody utility from the next-sanity package makes signature verification straightforward.
Step 1: Attach tags to data-fetching functions
// lib/queries.ts
import { cacheTag, cacheLife } from 'next/cache';
import { sanityClient } from './sanity';
async function getPosts() {
'use cache';
cacheTag('post'); // Tag matching the Sanity document type
cacheLife('max');
// Note: In production, apply pagination when data volume is large
return sanityClient.fetch(`*[_type == "post"]`);
}
async function getPostBySlug(slug: string) {
'use cache';
cacheTag('post', `post-${slug}`); // Attach both type tag and individual document tag
cacheLife('max');
return sanityClient.fetch(
`*[_type == "post" && slug.current == $slug][0]`,
{ slug }
);
}Step 2: Write the webhook-receiving Route Handler
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { parseBody } from 'next-sanity/webhook';
export async function POST(req: Request) {
const { body, isValidSignature } = await parseBody(
req,
process.env.SANITY_WEBHOOK_SECRET
);
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 });
}
// Use Sanity's _type ('post', 'author', etc.) as a tag to invalidate the entire type
revalidateTag(body._type);
// If a slug is present, precisely invalidate only the changed document
if (body.slug?.current) {
revalidateTag(`post-${body.slug.current}`);
}
return Response.json({ revalidated: true, now: Date.now() });
}Sanity Studio webhook configuration:
- Filter:
*[_type == "post"](fires only on post type changes) - HTTP Method: POST
- Secret header: set the same value as the environment variable in
x-sanity-webhook-secret
Contentful Integration
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const secret = req.headers.get('x-webhook-secret');
if (secret !== process.env.CONTENTFUL_REVALIDATE_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
const payload = await req.json();
const contentType = payload.sys?.contentType?.sys?.id; // 'blogPost', 'author', etc.
revalidateTag(contentType); // Invalidate entire content type
revalidateTag(`entry-${payload.sys.id}`); // Invalidate specific entry ID
return Response.json({ revalidated: true });
}Hierarchical Tag Design — Bulk Refresh of Related Posts on Author Update
Designing tags hierarchically lets a single revalidateTag call refresh all associated content at once.
// lib/queries.ts
import { cacheTag, cacheLife } from 'next/cache';
async function getPostBySlug(slug: string) {
'use cache';
const post = await db.post.findUnique({
where: { slug },
include: { author: true },
});
// Return early if post is null (prevents undefined from mixing into tags)
if (!post) return null;
// Attach both the post's unique tag and the author tag
// When author info changes, a single revalidateTag(`author-${id}`) also invalidates this post
cacheTag('post', `post-${slug}`, `author-${post.author.id}`);
cacheLife('max');
return post;
}// app/api/revalidate/route.ts — handling author update events
if (payload._type === 'author') {
revalidateTag(`author-${payload._id}`);
// All post caches belonging to this author are invalidated together
}Fallback TTL Pattern for Missed Webhooks
Webhooks can be missed due to network errors or CMS outages. Setting a fallback TTL ensures automatic refresh after a set period even in the worst case.
import { cacheTag, cacheLife } from 'next/cache';
async function getPost(slug: string) {
'use cache';
cacheTag(`post-${slug}`);
cacheLife({
max: 3600, // Maximum cache lifetime: 1 hour
revalidate: 3600, // Safety net: auto-refresh after 1 hour even if webhook is missed
});
return db.post.findUnique({ where: { slug } });
}Pros and Cons
Advantages
| Item | Detail |
|---|---|
| Immediate reflection on edit | Publish button click → webhook → cache invalidation — completes within seconds |
| Selective invalidation by tag | Unrelated content caches remain intact, no unnecessary rebuilds |
| stale-while-revalidate | Previous cache continues serving right after invalidation, no UX degradation |
| Cross-layer sharing | Server Components, Route Handlers, and Server Actions share the same tags |
| Efficient vs. TTL 0 | Content freshness guaranteed without disabling the entire cache |
Disadvantages and Caveats
| Item | Detail | Mitigation |
|---|---|---|
| Possible refresh delay | Actual refresh only happens when someone visits after revalidateTag is called |
Use updateTag or Vercel ISR pre-render |
| Risk of missed webhooks | Cache may remain permanently stale on webhook failure | Recommend setting a fallback TTL via cacheLife() |
| Webhook security is mandatory | Without secret verification, external parties can launch infinite cache invalidation attacks | HMAC signature verification or secret header required |
| Tag limits | Maximum 128 tags, maximum 256 characters per string | Design naming strategy in advance for multilingual content or per-user tag granularity |
| Deployment environment constraints | Self-hosted servers outside Vercel require a separate cache adapter | Integrate a Node.js custom cache handler or Redis adapter |
Webhook security details: Comparing secrets in the header with a simple string comparison (===) can be vulnerable to timing attacks, where the response time varies slightly depending on whether the two values match. An attacker can repeatedly measure this time difference to infer the secret one character at a time. Using crypto.timingSafeEqual() or an official CMS SDK (such as next-sanity's parseBody) mitigates this risk.
Most Common Mistakes in Practice
- Declaring
'use cache'without a tag — OmittingcacheTag()means callingrevalidateTag()will invalidate no cache at all. It's best to always use'use cache'andcacheTag()together. - Skipping webhook secret verification — Rapidly testing during development and deploying without secret validation leaves a public endpoint vulnerable to cache DoS attacks.
- Not setting a fallback TTL — Relying solely on tag-based invalidation and omitting
cacheLife()means stale cache will be served indefinitely if a webhook is missed. It is recommended to always set a minimal safety-net TTL alongside it.
Closing Thoughts
The combination of cacheTag() + revalidateTag() is the most practical approach for converting the uncertainty of time-based revalidation into precise, CMS event-driven control.
Here are 3 steps you can take right now:
- Add the
'use cache'directive andcacheTag()to your existing data-fetching functions. Import them withimport { cacheTag, cacheLife } from 'next/cache', attach both a type tag and an individual ID tag in the formcacheTag('post', \post-${slug}`), and set the default lifetime withcacheLife('max')`. - Create a Route Handler at
app/api/revalidate/route.ts, and implement the CMS webhook secret verification logic along with therevalidateTag()call. If you're using Sanity, you can usenext-sanity'sparseBody; for Contentful, a header-based secret check works. - Register the webhook in the CMS dashboard and send a test payload to confirm that the cache is actually being refreshed. In a Vercel environment, you can immediately verify whether
revalidateTagwas called in the Functions logs.
Next article: Advanced caching strategies for controlling the CDN layer using
cacheLife()profile customization and theuse cache: remote/use cache: privatedirectives
References
- cacheTag API Reference | Next.js
- revalidateTag API Reference | Next.js
- updateTag API Reference | Next.js
- use cache directive | Next.js
- How Revalidation Works | Next.js
- Caching and Revalidating | Next.js
- Tag-based Revalidation course | Sanity
- Caching and Revalidation in Next.js | Sanity Docs
- Sanity Webhooks and On-demand Revalidation in Next.js | Sanity Guide
- Sanity Webhooks and On-demand Revalidation in Next.js | Victor Eke
- revalidateTag & updateTag In Next.js | DEV Community
- Next.js 15 Caching — use cache, dynamicIO and tags in practice | uniquedevs