Sanity Scheduled Publishing with Next.js ISR Revalidation
By Nayan Kyada · · 7 min read
Part of The Sanity + Next.js Guide
Sanity scheduled publishing lets editors set a future publish date on any document. The problem is that Next.js has no idea a schedule just fired — the cached page keeps serving stale content until something tells the router to revalidate. This post shows exactly how I wire those two systems together so a post scheduled for 09:00 goes live at 09:00, not whenever the next organic revalidation happens to occur.
How sanity scheduled publishing actually works
Scheduling in Sanity has changed a few times, so it's worth knowing which version you're on:
- Scheduled Drafts (Studio v4.14.0+, Growth plan and above) is the current way to schedule a single document. It's built into Studio and is a single-document Content Release: the editor picks a time, the draft is locked, and Sanity publishes it at that time. For coordinated multi-document launches, use Content Releases.
- Scheduled Publishing (the older feature) moved from the
@sanity/scheduled-publishingplugin into Studio core in v3.39.0, and Sanity deprecated it in October 2025 in favour of Scheduled Drafts. If you still have the plugin installed, remove it.
Either way, when the scheduled time arrives Sanity publishes the document exactly like a manual publish action. What it does not do is ping your front-end. That's your job.
Legacy schedules live outside your dataset and are managed through the (also deprecated) Scheduling API, so you can't query them with GROQ. Scheduled drafts are releases, so you can query them with releases::all(). Either way, for triggering revalidation in Next.js the right tool is a Sanity webhook on the document being published, not a polling loop.
Setting up scheduling in Studio
On a current Studio there is nothing to install: Scheduled Drafts is on by default for Growth plans and above, and editors get a Schedule publish option on the document. You only touch sanity.config.ts if you want to turn it off (scheduledDrafts: { enabled: false }) or limit it to certain document types via document actions.
If you're maintaining an older Studio that still uses the legacy feature, its config lives at the top level of defineConfig, not in plugins:
// sanity.config.ts (legacy Scheduled Publishing — deprecated)
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
export default defineConfig({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
plugins: [structureTool()],
scheduledPublishing: {
enabled: true,
inputDateTimeFormat: 'dd/MM/yyyy HH:mm',
},
schema: {
types: [
// your document types
],
},
})Until the scheduled time, the content is not published, so your public queries don't see it. With Scheduled Drafts it sits in a release version (versions.<releaseId>.<documentId>); with the legacy feature it stays a regular draft.
Triggering ISR when a scheduled document goes live
When Sanity publishes a scheduled document, the published document is created or updated in the dataset — the same signal as a regular publish. That means the webhook you already have for on-demand ISR fires for scheduled publishes too, at no extra configuration cost.
If you haven't wired webhooks to ISR yet, here's the minimal route handler. It receives a Sanity GROQ-powered webhook, verifies the HMAC signature, and calls revalidateTag for the affected document.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { createHmac, timingSafeEqual } from 'crypto'
const SECRET = process.env.SANITY_REVALIDATE_SECRET!
function verifySignature(rawBody: string, signatureHeader: string): boolean {
if (!signatureHeader) return false
// Sanity's signature header looks like "t=<timestamp>,v1=<base64url-digest>",
// an HMAC-SHA256 over `${timestamp}.${rawBody}` — not a plain digest of the body.
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=')),
) as { t?: string; v1?: string }
if (!parts.t || !parts.v1) return false
const expected = createHmac('sha256', SECRET)
.update(`${parts.t}.${rawBody}`)
.digest('base64url')
try {
return timingSafeEqual(
Buffer.from(parts.v1, 'base64url'),
Buffer.from(expected, 'base64url'),
)
} catch {
return false
}
}
export async function POST(req: NextRequest) {
const rawBody = await req.text()
const signature = req.headers.get('sanity-webhook-signature') ?? ''
if (!verifySignature(rawBody, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const payload = JSON.parse(rawBody) as {
_type: string
slug?: { current: string }
}
// Revalidate the document type cache and the specific slug
// Next.js 16: the single-argument form is deprecated; pass a profile
revalidateTag(payload._type, 'max')
if (payload.slug?.current) {
revalidateTag(`${payload._type}:${payload.slug.current}`, 'max')
}
return NextResponse.json({ revalidated: true, ts: Date.now() })
}In your project settings at sanity.io/manage under API → Webhooks, create a webhook pointing at https://yourdomain.com/api/revalidate. Set the filter to _type == "post" (or whatever document type you schedule), check Create, Update, and Delete, paste in your HMAC secret, and enable it.
The webhook filter runs server-side in Sanity's infrastructure, so when the scheduler publishes the document it evaluates to true and Sanity sends the POST shortly after publication.
How ISR cache tagging interacts with scheduled content
In your data-fetching layer, tag every fetch call that depends on a document type:
// lib/sanity/queries.ts
import { client } from './client'
export async function getPostBySlug(slug: string) {
const query = `
*[_type == "post" && slug.current == $slug] [0] {
_id,
title,
publishedAt,
slug,
body
}
`
return client.fetch(
query,
{ slug },
{
// only published documents — excludes drafts and release versions
perspective: 'published',
next: {
// revalidate as a safety net — webhook is the primary trigger
revalidate: 3600,
tags: [`post`, `post:${slug}`],
},
},
)
}The revalidate: 3600 is a safety net only. For scheduled content you want the webhook to be the trigger, because revalidate: 3600 could mean up to an hour of lag between publication and the cached page updating. With the webhook in place the lag is usually seconds — Sanity's webhook delivery latency plus the time Next.js needs to mark the tag stale. With revalidateTag(tag, 'max') the first visitor after that may still get the stale page while the fresh one is generated in the background; use { expire: 0 } as the profile if you need the very next request to block on fresh data.
Handling the "scheduled doc is still a draft" edge case
Before the publish time, the document exists only as a draft (legacy scheduling) or as a release version (Scheduled Drafts). Your Studio preview can show it, but public queries using perspective: 'published' correctly exclude it. Don't rely on a !(_id in path("drafts.**")) filter with the raw perspective: it doesn't exclude versions.** documents. With the published perspective, nothing leaks to the public site before the schedule fires.
The one gotcha: if you have a sitemap that reads all published posts and an editor schedules a post for midnight, the sitemap stays accurate because the document isn't published yet. After the webhook fires and revalidation runs, the next sitemap request picks up the new slug automatically, assuming your sitemap route also uses revalidateTag or a short revalidate window.
Confirming it works end-to-end
Schedule a test document two minutes ahead. Watch the Sanity webhook logs in the dashboard — you'll see a delivery attempt appear within seconds of the publish time. Check your Next.js logs for the revalidated: true response. Then fetch the public page: it should reflect the new content. If the webhook shows a non-2xx response, the most common cause is a signature mismatch from a stale or mismatched SANITY_REVALIDATE_SECRET environment variable between your Sanity webhook config and your deployed function.
Frequently asked questions
01Does Sanity scheduled publishing automatically update my Next.js site when it fires?
No. Sanity's scheduler publishes the document in your dataset at the chosen time, but it has no awareness of your Next.js cache. Without a webhook wired to `revalidateTag`, the cached page keeps serving stale content until the next organic revalidation window — which for a `revalidate: 3600` safety net could mean up to an hour of lag.
02What is SANITY_REVALIDATE_SECRET and how is it used with scheduled publishing?
It's the shared HMAC secret used to verify that a revalidation request genuinely came from Sanity's webhook and not a forged request — set it in both your Sanity webhook config and your Next.js route handler's environment variables, then verify the `sanity-webhook-signature` header with `createHmac` and `timingSafeEqual` (or `parseBody` from `next-sanity/webhook`), as shown in the route handler below. If you're revalidating on every document type across the whole site rather than just scheduled content, see [batching revalidation tags in a single route handler](/blog/sanity-webhook-revalidation-batching-tags-nextjs-isr) for the higher-volume version of this pattern.