Next.js 15 | Implementing Selective CMS Cache Invalidation with cacheTag + Webhook
A Headless CMS is an architecture that separates content management from frontend rendering — Sanity, DatoCMS, and Contentful are representative examples. Anyone who has worked with this setup has likely encountered the following situation: an editor updates a product price or news article, but the live site requires a full rebuild before the change is reflected. Even with ISR (Incremental Static Regeneration) set to revalidate: 60, there is a delay of up to 60 seconds after an edit, and the longer the TTL, the more stale the data becomes. In e-commerce, a mispriced product leads to increased customer support requests; in news, a correction can remain invisible to readers.
Next.js 15 addresses this problem at its root with cache primitives: use cache, cacheTag(), and revalidateTag(). By connecting a CMS Webhook to these APIs, you can build a precise invalidation pipeline that selectively discards only the cache entries related to changed content — no full route rebuild, no waiting for TTL expiry.
This article explains how cacheTag() + revalidateTag() work, and walks through building a complete pipeline that receives CMS Webhooks in a Route Handler and selectively invalidates only the affected cache. It includes real integration examples with Sanity and DatoCMS, as well as practical caveats you will encounter in production.
Core Concepts
End-to-End Pipeline Flow
Before looking at code, here is the full flow at a glance.
CMS content is edited
│
▼
Webhook event sent (HTTP POST)
│
▼
Next.js Route Handler (app/api/revalidate/route.ts)
│ ① Validate secret token
│ ② Determine scope of impact from payload
▼
revalidateTag(`post:${id}`) + revalidateTag('posts')
│
▼
Only cache entries tagged with those tags are discarded
│
▼
Fresh data returned starting from the next requestuse cache — Explicit Cache Boundary Declaration
In previous versions of Next.js, caching was spread across multiple APIs: fetch() options, unstable_cache(), route segment configuration, and more. Next.js 15 is moving toward a single unified primitive — the use cache directive. It is currently in a parallel-use phase alongside unstable_cache, and use cache is recommended for new projects.
async function getPost(id: string) {
'use cache'
cacheLife('days') // Default TTL: stale 1 hour, revalidate 1 day, expire 1 week
cacheTag(`post:${id}`, 'posts')
return db.post.findUnique({ where: { id } })
}Declaring 'use cache' at the top of a function caches its return value. It can be applied to Server Components, Server Actions, and regular async functions.
cacheLife()default preset TTLs: Next.js provides'seconds','minutes','hours','days','weeks', and'max'presets.'hours'means stale 5 min · revalidate 1 hour · expire 1 day;'days'means stale 1 hour · revalidate 1 day · expire 1 week. You can customize these via thecacheLifesetting innext.config.ts.
dynamicIOflag: Enablingexperimental: { dynamicIO: true }innext.config.tscauses dynamic data access withoutuse cacheto be detected as a build-time error. Setting this when starting a new project makes missing cache declarations explicit and easy to spot.
cacheTag() — Attaching Tags to Cache Entries
cacheTag() is called inside a use cache block to associate one or more string tags with a cache entry.
cacheTag(`post:${id}`) // Tag for an individual entry
cacheTag(`post:${id}`, 'posts') // Attach multiple tags at once
cacheTag(`author:${authorId}`) // Relationship-based tagTag naming strategy: A type:id hierarchical structure using a colon (:) as a separator has become the industry standard. There are two reasons for this. First, colons are not reserved in URLs or file paths, so the risk of tag collision is low. Second, prefixing with a type like post:abc naturally separates namespaces from other content types. post:abc identifies a single specific post, while posts is attached to the full list and used when a "full invalidation" is needed. This multi-tag strategy is the core of this pattern.
revalidateTag() — Selective Tag-Based Invalidation
revalidateTag(tag) invalidates all cache entries that have been tagged with the given tag. The current official signature is as follows.
// Current actual signature
revalidateTag(tag: string): voidThe default behavior follows Stale-While-Revalidate (SWR) semantics. After invalidation, the first request immediately receives the existing (stale) cache while new data is regenerated in the background. The user receives the page without any response delay, and subsequent requests receive fresh data.
When immediate expiry is needed: An immediate-expiry option in the form of
revalidateTag(tag, { expire: 0 })is not currently supported in the official Next.js documentation. Immediate expiry is possible through separate experimental mechanisms such asunstable_expireTag. Check the official release notes to confirm support before applying to production.
Practical Implementation
Example 1: Basic Webhook Pipeline Setup
This is the most fundamental structure: attach tags at the data layer, then receive the Webhook in a Route Handler and trigger invalidation.
Step 1 — Attach tags at the data layer
// lib/posts.ts
import { cacheTag, cacheLife } from 'next/cache'
export async function getPost(id: string) {
'use cache'
cacheTag(`post:${id}`, 'posts') // Attach both individual tag and list tag
cacheLife('days')
const res = await fetch(`https://api.example.com/posts/${id}`)
if (!res.ok) throw new Error(`Failed to fetch post: ${id}`)
return res.json()
}
export async function getPosts() {
'use cache'
cacheTag('posts') // Attach list-only tag
cacheLife('hours') // Default TTL: stale 5 min, revalidate 1 hour, expire 1 day
const res = await fetch('https://api.example.com/posts')
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
}Separating list vs. detail cache:
getPostreceives bothpost:${id}andpoststags. When a specific post is edited, callingrevalidateTag('posts')refreshes both the list cache and that post's detail cache. SincegetPostsholds only thepoststag, independent invalidation of the list page is also possible.
Step 2 — Webhook-receiving Route Handler
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { headers } from 'next/headers'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const headersList = await headers()
const secret = headersList.get('x-webhook-secret')
if (secret !== process.env.WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
let payload: { type?: string; id?: string }
try {
payload = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const { type, id } = payload
if (!id || !type) {
return Response.json({ error: 'Missing required fields: type, id' }, { status: 400 })
}
revalidateTag(`post:${id}`) // Discard only the modified post's cache
revalidateTag(type) // Also refresh the list cache for that type
return Response.json({ revalidated: true, tag: `post:${id}` })
}| Code point | Role |
|---|---|
x-webhook-secret header validation |
Blocks unauthorized invalidation requests from sources other than the CMS |
try/catch JSON parsing |
Prevents server errors caused by malformed payloads |
!id || !type guard |
Returns an explicit error when required fields are missing |
revalidateTag(\post:${id}`)` |
Discards the cache for only the one changed post |
revalidateTag(type) |
Simultaneously refreshes all caches of that type, including list pages |
Example 2: Sanity CMS Integration
Sanity officially supports the tag-based caching pattern via the next-sanity package. Configure a sanityFetch() wrapper so that tags are automatically attached when querying data with GROQ (Sanity's dedicated query language).
// lib/sanity.ts
import { createClient } from '@sanity/client'
import { cacheTag, cacheLife } from 'next/cache'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: false,
})
export async function sanityFetch<T>({
query,
tags,
}: {
query: string
tags: [string, ...string[]] // Enforced to require at least one tag
}) {
'use cache'
cacheTag(...tags)
cacheLife('hours')
return client.fetch<T>(query)
}// app/shop/[slug]/page.tsx — E-commerce product page example
import { sanityFetch } from '@/lib/sanity'
interface Product {
title: string
price: number
slug: { current: string }
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await sanityFetch<Product>({
query: `*[_type == "product" && slug.current == $slug][0]`,
tags: [`product:${params.slug}`, 'products'],
})
return (
<article>
<h1>{product.title}</h1>
<p>₩{product.price.toLocaleString()}</p>
</article>
)
}In the Sanity Webhook handler, validate the request signature using the parseBody utility built into next-sanity, then perform invalidation.
// app/api/revalidate/route.ts (Sanity)
import { revalidateTag } from 'next/cache'
import { parseBody } from 'next-sanity/webhook'
export async function POST(request: Request) {
const { isValidSignature, body } = await parseBody<{
_type: string
_id: string
slug?: { current: string }
}>(request, process.env.SANITY_WEBHOOK_SECRET)
if (!isValidSignature) {
return Response.json({ error: 'Invalid signature' }, { status: 401 })
}
const { _type, _id, slug } = body
if (slug?.current) {
revalidateTag(`product:${slug.current}`)
}
revalidateTag(_type) // Invalidate all content of this type
return Response.json({ revalidated: true })
}
parseBody: A utility provided by thenext-sanitypackage that validates the HMAC-SHA256 signature of Webhooks sent by Sanity. IfisValidSignatureisfalse, the signature does not match and the request should be rejected. Thetags: [string, ...string[]]type prevents silent failures at compile time when an empty array is passed, which would result in no tags being attached.
Example 3: DatoCMS Native Cache Tags Integration
DatoCMS supports a mode where the Webhook payload directly includes a list of affected cache tags (cache_tags). You can use the payload as-is, with no need for custom tag-calculation logic.
// app/api/revalidate/route.ts (DatoCMS)
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const secret = request.headers.get('x-webhook-token')
if (secret !== process.env.DATOCMS_WEBHOOK_TOKEN) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
let cache_tags: string[]
try {
const body = await request.json() as { cache_tags?: string[] }
cache_tags = body.cache_tags ?? []
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
// revalidateTag is synchronous, so iterate with forEach
cache_tags.forEach((tag) => revalidateTag(tag))
return Response.json({ revalidated: true, tags: cache_tags })
}DatoCMS Cache Tags API: When content changes, DatoCMS automatically computes all cache tags associated with that record and sends them in a
cache_tagsarray. Even in complex content relationship graphs, the CMS guarantees the invalidation scope, which keeps client-side logic simple. SincerevalidateTagis a synchronous function, it is handled withforEachrather thanawait Promise.all.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Precise selective invalidation | Only caches related to changed content are discarded; unrelated pages retain their cached state |
| No rebuild required | Handled immediately at runtime, without regenerating full routes as ISR does |
| Multiple tag support | Multiple tags can be attached to a single cache entry, allowing flexible choice between individual and full invalidation |
| Server/data layer integration | The same API applies to both Server Components and the data fetching layer |
| CMS ecosystem support | Major headless CMSes including Sanity, DatoCMS, and Contentful provide official integration patterns |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| SWR delay | The default behavior is Stale-While-Revalidate, so stale data may be returned on the first request immediately after invalidation | If immediate expiry is needed, review experimental APIs such as unstable_expireTag after checking the official documentation |
| Tag management complexity | As content relationships grow more complex, tag design cost increases; over-tagging can degrade performance | Standardize on type:id hierarchy and centrally manage tag constants in a dedicated file |
| Webhook reliability | If the CMS → Next.js Webhook delivery fails, the cache will not be updated | Configure a retry policy on the CMS side and always include secret token validation in the handler |
use cache experimental status |
As of 2025, this is still an experimental feature; stability monitoring is needed for production adoption | Periodically check Next.js release notes and track when it stabilizes |
| Multi-instance environments | Cache synchronization across servers is not supported in self-hosted environments | Evaluate use cache remote (Vercel only) or a Redis-based cache adapter |
| Per-user cache collision | Applying use cache at the page level to responses that differ per user — such as session data or personalized feeds — risks cross-exposing data |
Apply use cache only to the shared data layer; use client-side fetching or cookie scoping for per-user data |
The Most Common Mistakes in Practice
-
Omitting Webhook secret validation: Deploying a Route Handler publicly without a
WEBHOOK_SECRETenvironment variable means anyone can triggerrevalidateTag()arbitrarily. It is strongly recommended to include secret header validation in every Webhook handler. -
Attempting to call
revalidateTagfrom a Client Component:revalidateTag()is a server-only function. Importing and calling it from a Client Component will produce a runtime error. It must only be called via a Server Action or Route Handler. -
Designing tags that are too broad: Using only a single top-level tag like
allorcontentfor all content means a trivial edit can wipe out the entire cache. It is recommended to design with atype:idhierarchy that separates individual invalidation from full invalidation.
Closing Thoughts
The combination of cacheTag() + revalidateTag() + Webhook is a precise Next.js cache invalidation strategy that simultaneously satisfies three conditions: no build, no delay, and only the relevant cache. If you want to fundamentally solve the data freshness problem between a CMS and your frontend, this pipeline is a great starting point. However, since use cache is still experimental as of 2025, it is recommended to confirm its stable status in the Next.js release notes before adopting it in a production environment.
Three steps you can take right now:
- Add
experimental: { dynamicIO: true }tonext.config.ts. Existing data fetching functions that lackuse cachewill surface as build errors, making it easy to identify where tags need to be attached. - **Apply the
'use cache'andcacheTag(\post:${id}`, 'posts')pattern to data layer functions such as those inlib/posts.tsto design your tag hierarchy.** Starting with two levels —type:id+type-wide` — is recommended. - Create
app/api/revalidate/route.tsand connect it in your CMS's Webhook settings page to send events to that URL. With just secret token validation and arevalidateTag()call, the complete pipeline is in place.
Next article: Upcoming coverage of customizing
cacheLife()presets and usinguse cache remote(Vercel only) to achieve cache consistency in multi-instance environments.
References
- Functions: cacheTag | Next.js Official Docs
- Functions: revalidateTag | Next.js Official Docs
- Directives: use cache | Next.js Official Docs
- Getting Started: Caching and Revalidating | Next.js
- Our Journey with Caching | Next.js Official Blog
- Tag-based revalidation | Sanity Learn
- Caching and revalidation in Next.js | Sanity Docs
- Sanity Webhooks and On-demand Revalidation in Next.js | Sanity
- DatoCMS Cache Tags and Next.js | DatoCMS Docs
- Mastering Next.js 15 Caching: dynamicIO, "use cache" & More | Strapi Blog
- Fix over-caching with Dynamic IO caching in Next.js 15 | LogRocket
- NextJS Tag Revalidation Caching Strategy | RudderStack
- Cache Revalidation Options · vercel/next.js Discussion #78513
- revalidateTag & updateTag In NextJs | DEV Community