InstaWebhook
August 17, 2026By InstaWebhook TeamRetries and Replay

Reliable Next.js On-Demand ISR: Handling Contentful & Sanity Webhooks Without Stale Content

Reliable Next.js On-Demand ISR: Handling Contentful & Sanity Webhooks Without Stale Content Pairing a headless CMS like Contentful or Sanity with Next.js is a common way to get the...

Reliable Next Js On Demand ISR Handling Contentful Sanity Webhooks Without Stale Content

Reliable Next.js On-Demand ISR: Handling Contentful & Sanity Webhooks Without Stale Content

Pairing a headless CMS like Contentful or Sanity with Next.js is a common way to get the speed of static generation with the flexibility of a real content workflow. With on-demand Incremental Static Regeneration (ISR), an editor publishes something, a webhook fires, and Next.js purges the cache for that page.

In production, this chain breaks more often than the happy-path diagrams suggest. Webhooks get dropped, functions cold-start past the response window, deploys reject traffic for a few seconds, and an editor ends up asking why their update isn't live.

This piece looks at why direct CMS-to-Next.js webhooks fail, what Contentful's and Sanity's actual delivery guarantees are (verified against their current docs, not folklore), and how putting a small intake layer in front of your revalidation endpoint closes most of the gap. It also updates a few "well-known" limits — Vercel's function timeout in particular — that have changed recently enough that older advice is now wrong.

The Core Problem: Direct Webhook Delivery Is Fire-and-Forget

Code example
┌─────────────────┐       HTTP POST (Direct)        ┌──────────────────────┐
│  Headless CMS    │ ──────────────────────────────>│  Next.js Route        │
│ (Contentful /     │   Must respond within a hard   │  Handler               │
│  Sanity)          │   time limit                   │ (/api/revalidate)     │
└─────────────────┘ <──────────────────────────────  └──────────────────────┘
                       200 OK / 5xx / Timeout

When content changes, the CMS builds a payload, POSTs it straight to your route handler, and expects an HTTP response inside a fixed window. Your handler verifies a signature, calls revalidateTag() or revalidatePath(), and returns 200 OK.

The failure mode is structural: webhooks are fire-and-forget HTTP calls with a hard clock running. If your endpoint doesn't answer cleanly in time, the CMS considers the delivery failed — and depending on the CMS, it may never try again.

What Contentful and Sanity Actually Guarantee

Both platforms document a 30-second hard timeout on webhook delivery, but their retry behavior differs in ways worth knowing precisely rather than assuming.

BehaviorContentfulSanity (GROQ-powered webhooks)
Hard timeout30 seconds30 seconds
Retries on timeoutNone — a timed-out request is marked failed and not retriedRetried like other failures (Sanity aims for at-least-once delivery)
Retries on 429 / 5xxUp to 2 additional attempts, ~30s apart (3 attempts total, ~1 minute window)Up to 2 retries
Delivery concurrencyMultiple webhooks can be in flight at onceLimited to one concurrent request per webhook — Sanity queues the next delivery rather than firing bursts in parallel
Idempotency headerX-Contentful-Idempotency-Key — a SHA-256 hash of the event, deduping is your responsibilityidempotency-key (lowercase) — same principle
Auto-save behaviorDocuments edited in the last 5 seconds count as "active"; a minute of continuous editing with an auto_save webhook configured fires it up to 12 timesRapid keystrokes in Studio can fire a webhook on nearly every mutation if your filter is broad

Sources: Contentful webhooks overview, Sanity GROQ-powered webhooks docs, Hookdeck's Sanity webhooks guide.

Two corrections worth flagging against common assumptions:

  1. Contentful never retries a timeout. It only retries on an explicit 429 or 5xx status code. If your function hangs past 30 seconds without returning anything, that event is gone — there is no second chance.
  2. Sanity doesn't burst webhooks in parallel. It caps concurrency at one in-flight request per webhook. A flurry of edits produces a fast sequence of requests, not a simultaneous pile of them — which matters for how you think about the failure mode (queueing/backpressure on your endpoint, not concurrent overload).

Four Ways Direct Webhooks Fail in Production

1. Serverless timeouts — but this limit moved recently

The old advice was "Vercel functions time out at 10 seconds by default, so keep your handler fast." That's now out of date. With Fluid Compute, which Vercel enabled by default, the current defaults are:

PlanDefault durationMaximumExtended maximum
Hobby300s (5 min)300s
Pro300s (5 min)800s1800s (beta)
Enterprise300s (5 min)800s1800s (beta)

(Source: Vercel — Configuring Maximum Duration, updated July 2026.)

So a slow revalidation handler is far less likely to hit a Vercel function timeout than it was a couple of years ago. What hasn't changed:

  • Edge Functions still cap at 30 seconds on every plan, and that's a hard ceiling, not a default you can raise.
  • Cold starts are still real. A slow cold start can eat into whatever time budget you have, and if your handler does synchronous work — fetching fresh data, hitting a search index, calling multiple revalidateTag()s that each do I/O — you can still lose the race, especially against Contentful's non-negotiable 30-second window (which is a CMS-side limit, independent of your host).
  • Other hosts (AWS Lambda default timeout, Netlify Functions, self-managed containers) still ship far tighter defaults than Vercel's current ones, so this failure mode is very host-dependent — check your specific platform rather than assuming Vercel's numbers apply.

2. Deployment window blackouts

During a deploy, there's a brief window where incoming requests can hit 502/503 while the old container tears down or the new one isn't fully routable yet. If an editor publishes at that exact moment and the CMS's retry budget is exhausted (Contentful: 2 retries over ~1 minute; Sanity: similarly short), the update won't show up until the next successful delivery or a manual revalidation.

3. Concurrency and rate limits

If an editor bulk-updates 50 entries, Contentful can fire up to 50 individual webhooks. Sanity, by contrast, self-limits to one in-flight request per webhook — so the pressure there shows up as a backlog rather than a stampede. Either way, if your handler makes an upstream call back to the CMS's API to fetch fresh data for every event, you can trip the CMS's own rate limits and start throwing 429s at yourself.

4. Multi-instance cache invalidation gaps — mostly a self-hosting problem

This is the failure mode most likely to be overstated. If you're deployed on Vercel, Vercel's own infrastructure propagates revalidateTag()/revalidatePath() calls across your app's instances automatically — this isn't something you need to build (Vercel/Next.js discussion #91826).

If you're self-hosting — Kubernetes, ECS, plain Docker behind a load balancer — this is a real and current gap. Next.js's default cache handler is local to each instance, so calling revalidateTag() on Instance A does nothing for Instances B and C until they separately notice. Next.js's own self-hosting guide confirms this and directs you to a custom cache handler with shared storage (Redis is the common choice) implementing updateTags() and refreshTags() (Next.js — How Revalidation Works, Self-Hosting guide). Community projects like @fortedigital/nextjs-cache-handler and nextjs-turbo-redis-cache implement this pattern if you'd rather not write it yourself.

The Fix: Decouple Receipt From Execution

The shape of the fix hasn't changed even as the specific numbers above have: put a thin, fast intake layer between the CMS and your Next.js route handler.

Code example
┌──────────────┐   1. POST    ┌──────────────────┐   2. 200 OK (fast)   ┌──────────────┐
│ Headless CMS │ ───────────> │  Intake / Queue   │ ───────────────────>│ Headless CMS │
│ (Contentful/ │              │  (Hookdeck,       │                      │ (Acknowledged│
│  Sanity)     │              │   QStash, etc.)   │                      │  immediately)│
└──────────────┘              └────────┬──────────┘
                                        │ persists + dedupes
                                        ▼
                               ┌──────────────────┐
                               │ Retries with      │
                               │ backoff           │
                               └────────┬──────────┘
                                        │ delivers when ready
                                        ▼
                               ┌──────────────────┐
                               │ Next.js App        │
                               │ /api/revalidate    │
                               └──────────────────┘

The intake service accepts the CMS's payload in well under a second, stores it durably, and returns 200 OK immediately — so the CMS's 30-second clock and thin retry budget are never in play. It then delivers to your actual Next.js endpoint on its own schedule, retrying with backoff if your app is mid-deploy or briefly down, and can dedupe bursts (auto-save, bulk edits) before they ever reach your handler.

Realistic options for the intake layer

Worth being specific here, since "just use a webhook proxy" hides real differences between tools:

  • Hookdeck — purpose-built for receiving and routing inbound webhooks, which is exactly this use case: ingest from Contentful/Sanity, queue, retry, replay, fan out to multiple destinations.
  • Upstash QStash — an HTTP-based message queue. You still need a small receiver to accept the CMS's POST, but you hand off to QStash for durable, retried delivery to your revalidation route, which is useful if you're already on Upstash's stack.
  • Svix — worth naming because it's often mentioned in the same breath as Hookdeck, but it's primarily built for outbound webhooks (a product sending webhooks to its own customers), not for ingesting someone else's inbound CMS events. It's the wrong tool for this specific problem even though it's a solid product for its actual use case.
  • InstaWebhook — a smaller, newer entrant offering durable intake, retries, replay, and dead-letter queues, with an optional "bring your own database" mode. It's real and functional, but it's a much younger product than Hookdeck or Svix with a correspondingly smaller track record — evaluate its current SLAs and feature set directly against your requirements rather than taking specific retry-count or backoff defaults on faith; check its docs at setup time.
  • Roll your own with a queue (SQS, Cloud Tasks, a Redis-backed job queue) if you want full control and already run that infrastructure.

Whichever you pick, the mechanism you want is the same: sub-second acknowledgment to the CMS, durable storage before your app ever sees the event, retries with backoff, deduplication, and a dead-letter queue you can replay after an incident.

Code: A Resilient Route Handler

The route handler logic itself hasn't changed much, but Next.js 16 changed how revalidateTag() behaves: it now takes an optional cache-life profile argument. Calling it with profile="max" marks the tag stale and serves stale-while-revalidate — content updates in the background while users still get a fast response. Omitting the second argument still works but expires the tag immediately, forcing the next visitor into a blocking revalidation (Next.js revalidateTag reference).

Code example
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag, revalidatePath } from 'next/cache';
import { isValidSignature } from '@sanity/webhook';

const REVALIDATE_SECRET = process.env.REVALIDATION_SECRET_TOKEN;
const SANITY_WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET;

export async function POST(req: NextRequest) {
  try {
    // 1. Authenticate — header or query param, matching what your intake layer forwards
    const authHeader = req.headers.get('x-revalidate-secret');
    const querySecret = req.nextUrl.searchParams.get('secret');

    if (authHeader !== REVALIDATE_SECRET && querySecret !== REVALIDATE_SECRET) {
      return NextResponse.json({ message: 'Invalid revalidation secret' }, { status: 401 });
    }

    const cmsSource = req.headers.get('x-cms-source') || 'generic';
    const bodyText = await req.text();
    const payload = JSON.parse(bodyText);

    // 2. Sanity
    if (cmsSource === 'sanity') {
      const signature = req.headers.get('sanity-signature') || '';
      if (SANITY_WEBHOOK_SECRET && !isValidSignature(bodyText, signature, SANITY_WEBHOOK_SECRET)) {
        return NextResponse.json({ message: 'Invalid Sanity signature' }, { status: 401 });
      }

      const { _type, slug, tags } = payload;

      if (Array.isArray(tags)) {
        tags.forEach((tag: string) => revalidateTag(tag, 'max'));
      }
      if (_type === 'post' && slug?.current) {
        revalidatePath(`/blog/${slug.current}`);
        revalidateTag('blog-posts', 'max');
      } else if (_type === 'page' && slug?.current) {
        revalidatePath(`/${slug.current}`);
      }

      return NextResponse.json({ revalidated: true, source: 'sanity', now: Date.now() });
    }

    // 3. Contentful
    if (cmsSource === 'contentful') {
      const topic = req.headers.get('x-contentful-topic') || '';
      const contentType = payload.sys?.contentType?.sys?.id;
      const entryId = payload.sys?.id;

      if (topic.includes('Entry.publish') || topic.includes('Entry.unpublish') || topic.includes('Entry.delete')) {
        if (contentType) revalidateTag(`content-type-${contentType}`, 'max');
        revalidateTag(`entry-${entryId}`, 'max');

        const slug = payload.fields?.slug?.['en-US'];
        if (contentType === 'blogPost' && slug) {
          revalidatePath(`/blog/${slug}`);
          revalidateTag('blog-list', 'max');
        }
      }

      return NextResponse.json({ revalidated: true, source: 'contentful', now: Date.now() });
    }

    // 4. Generic fallback (e.g. your intake layer's own retry payload shape)
    if (payload.tag) revalidateTag(payload.tag, 'max');
    if (payload.path) revalidatePath(payload.path);

    return NextResponse.json({ revalidated: true, now: Date.now() });
  } catch (error: any) {
    console.error('ISR Revalidation Error:', error);
    return NextResponse.json({ message: 'Error revalidating', error: error.message }, { status: 500 });
  }
}

Note the signature verification for Sanity happens against the raw body text before parsing — verifying after JSON.parse risks whitespace/formatting differences invalidating a legitimate signature.

Configuring the CMS Side

Contentful

  1. Settings → Webhooks → Add Webhook.
  2. Point the URL at your intake layer's ingestion endpoint, not directly at /api/revalidate.
  3. Scope Triggers to what you actually need — typically Entry: Publish/Unpublish/Delete and Asset: Publish/Unpublish/Delete. Don't subscribe to auto_save unless you specifically want the up-to-12-times-a-minute firehose.
  4. Add a custom header (e.g. x-cms-source: contentful) so your route handler can branch.
  5. Remember: Contentful already sends X-Contentful-Idempotency-Key automatically — use it for dedup rather than inventing your own scheme.

Sanity

  1. sanity.io/manage → API → Webhooks → Create Webhook.
  2. Set the dataset, point the URL at your intake layer.
  3. Write a GROQ filter that's as narrow as you can make it, e.g. _type in ["post","page","author","product"] && !(_id in path("drafts.**")) — this is your first line of defense against the Studio auto-save firehose, since Sanity fires on nearly every keystroke by default when a filter is broad.
  4. Use a GROQ projection to send only the fields you need (_type, _id, slug, tags) rather than the full document — smaller payloads, less to parse.
  5. Set the webhook secret and verify it server-side with @sanity/webhook's isValidSignature, against the raw request body.

Production Practices Worth Keeping

  • Acknowledge fast, work async. Both CMS's official guidance is to return 200 as early as possible and defer real work — this is the recurring theme across both platforms' own docs, not just a third-party opinion.
  • Use tags for collections, paths for individual routes. revalidateTag() for anything you'll want to bulk-invalidate (all blog posts, all products); revalidatePath() for a specific known URL.
  • Handle tombstones. Both CMS's send a distinct payload shape for deletions (Contentful: Entry.delete topic; Sanity: a deletion event in the GROQ trigger). Make sure deleted content actually revalidates to a 404 rather than continuing to serve a cached 200.
  • If self-hosting across multiple instances, invest in a shared cache handler now, not after the first stale-content incident. This is the one failure mode on this list that doesn't get better with just an intake layer — it's a Next.js caching architecture problem, orthogonal to webhook delivery.
  • Alert on your intake layer's dead-letter queue, and make replaying it a one-click, well-rehearsed action, not something you improvise during an incident.

Direct vs. Decoupled: What Actually Changes

Direct webhookDecoupled intake layer
CMS timeout riskReal — Contentful gives you 30s and zero timeout retriesEffectively zero — intake acks in milliseconds
Deploy-window dropsLost if retry budget (≈1 min) is exhausted mid-deployRetried until your app is back
Bulk-edit burstsCan trip your own rate limits or the CMS'sBuffered, deduplicated, throttled
Multi-instance staleness (self-hosted)Not addressed by the intake layer — separate fix neededSame — still needs a shared cache handler
Audit trail / replayWhatever the CMS's dashboard shows you, which is limitedFull delivery history, one-click replay

Key Takeaways

  • Contentful gives you a 30-second window and will not retry a timeout — only 429/5xx responses get retried, twice, over about a minute.
  • Sanity also enforces 30 seconds, retries a similar amount, and serializes concurrent deliveries per webhook rather than bursting them.
  • Vercel's default function timeout is no longer the tight 10-second window it used to be — check current numbers for your specific host and plan before optimizing around stale assumptions.
  • Multi-instance cache staleness is a self-hosting concern; Vercel handles this for you automatically.
  • An intake layer (Hookdeck, QStash, or similar) solves the "CMS gave up before my app answered" problem — it does not solve multi-instance cache propagation, which needs its own fix.
  • Verify signatures against the raw request body, and use each CMS's built-in idempotency header instead of building your own deduplication.

Sources