Skip to content
← All articles

From force-dynamic to on-demand ISR with Sanity webhooks

We started with force-dynamic on every page because it was easy. Then we measured the cost. Here's how we switched to on-demand revalidation.

From force-dynamic to on-demand ISR with Sanity webhooks

When you are building fast and testing content, `force-dynamic` is tempting. Every page renders fresh. No stale cache. No wondering why your changes are not showing up.

We shipped with it on every page. Then we thought about what that actually means.

The problem with force-dynamic

Every request hits the Sanity API. No CDN caching. No static generation. A page that could be served in 20ms from the edge instead takes 200-400ms while it fetches content, renders, and responds.

For a small site like ours, the performance difference is noticeable but not catastrophic. For a site with real traffic, it would be expensive and slow. It also means every page view counts against your Sanity API quota.

The right approach: static generation with on-demand revalidation. Pages are built once and served from the CDN. When content changes in Sanity, a webhook tells Next.js to rebuild only the affected pages.

defineLive and sanityFetch

Sanity's `next-sanity` package provides `defineLive`, which gives you two things: a `sanityFetch` function and a `SanityLive` component.

import { defineLive } from 'next-sanity/live'
import { client } from './client'

export const { sanityFetch, SanityLive } = defineLive({
  client,
  serverToken: process.env.SANITY_API_TOKEN,
  browserToken: process.env.SANITY_API_TOKEN,
})

`sanityFetch` replaces raw `client.fetch` calls in your page components. It handles cache tags automatically based on the GROQ query, so Next.js knows which pages depend on which content types.

`SanityLive` goes in the root layout. It enables real-time updates when draft mode is active, so editors see changes instantly without refreshing.

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const isDraftMode = (await draftMode()).isEnabled

return (
    <html lang="en">
      <body>
        {children}
        <SanityLive />
        {isDraftMode && <VisualEditing />}
      </body>
    </html>
  )
}

The webhook route

When an editor publishes a change in Sanity, a webhook fires. It hits our `/api/revalidate` endpoint, which tells Next.js to invalidate the relevant cache tags.

import { parseBody } from 'next-sanity/webhook'
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'

interface WebhookPayload {
  _type: string
  _id: string
  slug?: { current: string }
}

export async function POST(req: NextRequest) {
  try {
    const { body, isValidSignature } = await parseBody<WebhookPayload>(
      req,
      process.env.SANITY_REVALIDATE_SECRET,
      true
    )

if (!isValidSignature) {
      return new Response('Invalid signature', { status: 401 })
    }

if (!body?._type) {
      return new Response('Bad Request', { status: 400 })
    }

revalidateTag(body._type)

if (body.slug?.current) {
      revalidateTag(`${body._type}:${body.slug.current}`, 'default')
    }

return NextResponse.json({
      revalidated: true,
      type: body._type,
      id: body._id,
    })
  } catch (err) {
    console.error('Webhook revalidation error:', err)
    return new Response('Internal Server Error', { status: 500 })
  }
}

Three things matter here. First, `parseBody` validates the webhook signature. Without this, anyone could trigger revalidation. Second, we revalidate by content type, so publishing a blog post invalidates all pages that query posts. Third, we also revalidate by specific slug for targeted cache busting.

The result

Pages are statically generated at build time. They serve instantly from Vercel's edge network. When I publish a change in Sanity, the webhook fires, the affected pages regenerate, and the new content is live within seconds.

No `force-dynamic`. No stale content. No wasted API calls.

The setup took about 30 minutes. The performance improvement is permanent.

Next in the series: [Structured data, SEO, and the sitemap that builds itself](/digital-insights/structured-data-seo-and-the-sitemap-that-builds-itself)

Chris Ryan

Chris Ryan

Managing Director

17+ years in full-stack web development, most of it leading teams agency-side across e-commerce, CMS platforms, and bespoke applications. Specialises in infrastructure, system integration, and data privacy, with hands-on experience as a Data Protection Officer. Founded Innatus Digital in 2020 to offer the kind of honest, technically-led partnership that he felt was missing from the agency world.