ISR with Headless CMS — Webhook-Triggered Revalidation from CMS
In this tutorial, you will learn about ISR with Headless CMS. We cover key concepts, practical examples, and best practices to help you master this topic.
ISR with headless CMS updates static pages instantly when content changes via Webhooks, combining CMS flexibility with static performance.
What You'll Learn
By the end of this tutorial, you'll understand how to connect a headless CMS (Contentful, Sanity, Strapi) to ISR, configure webhooks for on-demand revalidation, handle media assets, and set up preview workflows.
Why It Matters
Content teams want instant publishing. ISR with CMS webhooks delivers this: editors publish in the CMS, a Webhook triggers revalidation, and the static site updates within seconds — no rebuild needed.
Real-World Use
A marketing team publishes a new landing page in Contentful. The CMS sends a webhook to the Next.js revalidation endpoint. The landing page and site navigation regenerate. Within 5 seconds, the new page is live and cached globally.
CMS + ISR Architecture
graph LR
A[CMS Editor
publishes content] --> B[CMS Webhook
HTTP POST]
B --> C[API Route
/api/revalidate]
C --> D[Verify secret]
D --> E[res.revalidate
affected paths]
E --> F[ISR re-renders
pages in background]
F --> G[Fresh static HTML
on CDN]
G --> H[Users see
updated content]
style A fill:#27ae60,color:#fff
style B fill:#4a90d9,color:#fff
style D fill:#e67e22,color:#fff
style G fill:#27ae60,color:#fff
Contentful + ISR
// pages/api/revalidate-contentful.js — Contentful webhook
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
// Contentful sends a secret token in the header
const token = req.headers['x-contentful-webhook-token'];
if (token !== process.env.CONTENTFUL_WEBHOOK_TOKEN) {
return res.status(401).json({ message: 'Invalid token' });
}
const { sys, fields } = req.body;
const contentType = sys.contentType?.sys?.id;
const slug = fields?.slug?.['en-US'];
const pathsToRevalidate = ['/']; // Always revalidate homepage
switch (contentType) {
case 'blogPost':
if (slug) {
pathsToRevalidate.push(`/blog/${slug}`);
}
pathsToRevalidate.push('/blog');
break;
case 'product':
if (slug) {
pathsToRevalidate.push(`/products/${slug}`);
}
pathsToRevalidate.push('/products');
break;
case 'category':
pathsToRevalidate.push(`/categories/${slug || fields?.name?.['en-US']}`);
pathsToRevalidate.push('/products');
break;
case 'page':
if (slug) {
pathsToRevalidate.push(`/${slug}`);
}
break;
default:
console.log(`Unknown content type: ${contentType}`);
}
try {
const results = await Promise.allSettled(
pathsToRevalidate.map(path => res.revalidate(path))
);
const errors = results
.filter(r => r.status === 'rejected')
.map(r => r.reason.message);
if (errors.length > 0) {
console.error('Revalidation errors:', errors);
}
res.json({
revalidated: errors.length === 0,
contentType,
slug,
paths: pathsToRevalidate,
errors
});
} catch (err) {
console.error('Webhook handler error:', err);
res.status(500).json({ error: err.message });
}
}
Sanity + ISR
// pages/api/revalidate-sanity.js — Sanity webhook
import { createClient } from '@sanity/client';
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
token: process.env.SANITY_API_TOKEN,
apiVersion: '2024-01-01',
useCdn: false,
});
export default async function handler(req, res) {
// Verify Sanity webhook signature
const signature = req.headers['sanity-webhook-signature'];
if (!verifySanitySignature(signature, req.body)) {
return res.status(401).json({ message: 'Invalid signature' });
}
const { _type, slug, _id } = req.body;
// Query affected document
const doc = await client.getDocument(_id);
if (!doc) {
return res.status(404).json({ message: 'Document not found' });
}
const pathsToRevalidate = ['/'];
// Determine paths based on document type
if (_type === 'post') {
const postSlug = doc.slug?.current;
if (postSlug) {
pathsToRevalidate.push(`/blog/${postSlug}`);
}
pathsToRevalidate.push('/blog');
// Also revalidate category pages
if (doc.categories) {
const categories = await client.fetch(
`*[_type == "category" && _id in $ids]{slug}`,
{ ids: doc.categories }
);
categories.forEach(cat => {
if (cat.slug?.current) {
pathsToRevalidate.push(`/categories/${cat.slug.current}`);
}
});
}
}
// Trigger revalidation
try {
await Promise.all(
pathsToRevalidate.map(path => res.revalidate(path))
);
console.log(`Sanity webhook: revalidated ${pathsToRevalidate.length} paths`);
res.json({ revalidated: true, paths: pathsToRevalidate });
} catch (err) {
console.error('Sanity webhook error:', err);
res.status(500).json({ error: err.message });
}
}
function verifySanitySignature(signature, body) {
const crypto = require('crypto');
const expected = crypto
.createHmac('sha256', process.env.SANITY_WEBHOOK_SECRET)
.update(JSON.stringify(body))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Strapi + ISR
// pages/api/revalidate-strapi.js — Strapi webhook
export default async function handler(req, res) {
const webhookToken = req.headers['x-strapi-webhook-token'];
if (webhookToken !== process.env.STRAPI_WEBHOOK_TOKEN) {
return res.status(401).json({ message: 'Invalid token' });
}
const { event, model, entry } = req.body;
const pathsToRevalidate = ['/'];
if (model === 'article' && entry) {
if (event === 'entry.publish' || event === 'entry.update') {
const slug = entry.slug;
pathsToRevalidate.push(`/blog/${slug}`);
pathsToRevalidate.push('/blog');
// Revalidate category if exists
if (entry.category?.slug) {
pathsToRevalidate.push(`/categories/${entry.category.slug}`);
}
}
}
if (model === 'product' && entry) {
if (event === 'entry.publish' || event === 'entry.update') {
pathsToRevalidate.push(`/products/${entry.slug}`);
pathsToRevalidate.push('/products');
}
}
try {
await Promise.all(
pathsToRevalidate.map(path => res.revalidate(path))
);
res.json({ revalidated: true, paths: pathsToRevalidate });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
Preview Mode for CMS
// pages/api/preview.js — CMS preview mode
export default async function handler(req, res) {
const { secret, slug, contentType } = req.query;
// Verify preview secret
if (secret !== process.env.CMS_PREVIEW_SECRET) {
return res.status(401).json({ message: 'Invalid token' });
}
// Enable preview mode
res.setPreviewData({
slug,
contentType,
timestamp: Date.now()
});
// Redirect to the preview page
const previewPath = contentType === 'product'
? `/products/${slug}`
: `/blog/${slug}`;
res.redirect(previewPath);
}
// In getStaticProps — detect preview mode
export async function getStaticProps({ params, preview = false, previewData }) {
// Use preview API when in preview mode
const host = preview
? 'preview.contentful.com' // Contentful preview API
: 'cdn.contentful.com'; // Contentful delivery API
const token = preview
? process.env.CONTENTFUL_PREVIEW_TOKEN
: process.env.CONTENTFUL_DELIVERY_TOKEN;
const client = contentful.createClient({
space: process.env.CONTENTFUL_SPACE_ID,
accessToken: token,
host
});
const entry = await client.getEntries({
content_type: 'blogPost',
'fields.slug': params.slug
});
if (!entry.items.length) {
return { notFound: true };
}
return {
props: {
post: entry.items[0].fields,
isPreview: preview,
previewData: previewData || null
},
// Don't cache preview pages
revalidate: preview ? 1 : 60
};
}
Common Mistakes
- Not verifying webhook signatures. Anyone who discovers your webhook URL can trigger revalidations. Always verify signatures or use secret tokens.
- Revalidating too many paths on each webhook. A single content change may affect 2-3 paths. Don't revalidate the entire site. Be specific.
- Not handling webhook failures. If the revalidation API is down, content updates are missed. Queue failed webhooks for retry.
- Missing preview mode for editors. Editors need to preview content before publishing. Implement preview mode with CMS preview APIs.
- Not differentiating between publish, unpublish, and update events. Unpublishing should revalidate paths to show 404. Updating should revalidate the same paths.
Practice Questions
- How do you verify webhook authenticity from different CMS platforms?
- What paths should you revalidate when a blog post is published?
- How do you implement preview mode for CMS editors?
- How do you handle content unpublishing through webhooks?
- How do you monitor webhook-triggered revalidation health?
Challenge: Build a complete CMS+ISR pipeline: set up Contentful with a blog post content type, create a webhook handler that revalidates the post and listing pages, implement preview mode with secret authentication, and monitor webhook success rates.
FAQ
Mini Project
Create a CMS-integrated blog with instant publishing: set up Contentful with 3 content models (blog post, category, author), configure webhooks for publish/unpublish events, implement preview mode, add a webhook testing dashboard, and verify end-to-end publishing flow.
What's Next
You've integrated CMS with ISR. Now explore common ISR Patterns and best practices for production applications.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro