Skip to content

ISR with Headless CMS — Webhook-Triggered Revalidation from CMS

DodaTech Updated 2026-06-28 6 min read

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

  1. Not verifying webhook signatures. Anyone who discovers your webhook URL can trigger revalidations. Always verify signatures or use secret tokens.
  2. Revalidating too many paths on each webhook. A single content change may affect 2-3 paths. Don't revalidate the entire site. Be specific.
  3. Not handling webhook failures. If the revalidation API is down, content updates are missed. Queue failed webhooks for retry.
  4. Missing preview mode for editors. Editors need to preview content before publishing. Implement preview mode with CMS preview APIs.
  5. Not differentiating between publish, unpublish, and update events. Unpublishing should revalidate paths to show 404. Updating should revalidate the same paths.

Practice Questions

  1. How do you verify webhook authenticity from different CMS platforms?
  2. What paths should you revalidate when a blog post is published?
  3. How do you implement preview mode for CMS editors?
  4. How do you handle content unpublishing through webhooks?
  5. 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

Which CMS platforms support webhook-triggered ISR?

Most headless CMS platforms support webhooks: Contentful, Sanity, Strapi, WordPress (with plugins), Ghost, and Prismic all have webhook capabilities.

How do I handle media asset updates via webhooks?

Media URLs may change. Include the media CDN URL in the page props. If a media CDN URL changes, the page re-renders and picks up the new URL.

Can I use ISR with a traditional CMS like WordPress?

Yes. WordPress has REST API and webhook plugins. Use WPGraphQL for GraphQL-based data fetching with ISR.

How do webhooks affect ISR rate limits?

Each webhook triggers revalidation API calls. Vercel's Pro plan includes 1M revalidations per month. Monitor usage and batch changes where possible.

What happens if the CMS is down during a build?

ISR handles this gracefully. During build, return fallback data. During revalidation, the stale cache continues serving. The next revalidation retries.

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