How I set up sanity scheduled publishing with Next.js ISR revalidation
Jun 26, 2026 · 5 min read
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
The Scheduled Publishing feature ships as a first-party Sanity plugin (@sanity/scheduled-publishing). Once installed it adds a schedules document type to your dataset and a calendar UI to Studio. When the scheduled time arrives, Sanity's cloud infrastructure publishes the document — it transitions from draft to published exactly like a manual publish action. What it does not do is ping your front-end. That's your job.
The plugin exposes a REST endpoint:
GET https://api.sanity.io/v2022-03-07/schedules/{projectId}/{dataset}?state=upcomingYou can poll it to see what's queued, but for triggering revalidation in Next.js the right tool is a Sanity webhook pointed at the document being published, not a polling loop.
Setting up the plugin
Install and register the plugin inside sanity.config.ts:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { scheduledPublishing } from '@sanity/scheduled-publishing'
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
],
},
})That's all the Studio side needs. After deploying, editors see a clock icon next to the Publish button. They pick a date, save, and the document enters a scheduled state — it's still a draft from the perspective of your GROQ queries until the moment it publishes.
One thing worth knowing: a scheduled document has _type: "schedule.metadata" in the dataset alongside the actual document. Your existing GROQ queries that filter on _type == "post" && defined(slug) naturally ignore it, so there's nothing to change there.
Triggering ISR when a scheduled document goes live
When Sanity publishes a scheduled document its event type in the mutations stream is create or update with transition: "appear" — 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(body: string, signature: string): boolean {
const expected = createHmac('sha256', SECRET)
.update(body)
.digest('hex')
try {
return timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex'),
)
} 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
revalidateTag(payload._type)
if (payload.slug?.current) {
revalidateTag(`${payload._type}:${payload.slug.current}`)
}
return NextResponse.json({ revalidated: true, ts: Date.now() })
}In Sanity's dashboard 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 fires it evaluates to true and Sanity sends the POST within a second or two of 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 && !(_id in path("drafts.**"))] [0] {
_id,
title,
publishedAt,
slug,
body
}
`
return client.fetch(
query,
{ slug },
{
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 typically two to five seconds — Sanity's webhook delivery latency plus the time Next.js needs to purge the tag and rebuild the static shell.
Handling the "scheduled doc is still a draft" edge case
Before the publish time, the document exists only in draft form. If your Studio preview fetches with perspective: "previewDrafts" you'll see it, but your public GROQ queries — which filter !(_id in path("drafts.**")) — correctly exclude it. 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.