Type something to search...
Scheduling Content Publishing in Sanity

Scheduling Content Publishing in Sanity

To schedule publishing in Sanity today, use scheduled drafts to publish a single document at a set time, or Content Releases to publish a group of documents together. Scheduled drafts are available from the Growth plan and need Studio v4.14.0 or later; Content Releases are available on certain Enterprise plans. The older Scheduled Publishing feature, first a plugin and later built into the Studio, was deprecated in October 2025, so existing projects should move to scheduled drafts or releases. On the Free plan, or when you need custom rules, you can build your own scheduler with a publishAt field and a cron job that publishes due drafts through the API.

Scheduling sounds simple until you get the details wrong: a post goes live but the cached home page doesn't show it, a scheduled document references an unpublished author, or two scheduling buttons confuse editors. This guide covers how Sanity's scheduling evolved, how scheduled drafts and Content Releases work, migrating from Scheduled Publishing, building a DIY scheduler, and making sure your front end shows scheduled content the moment it publishes.

Your Options at a Glance

OptionScopePlanStatus
Scheduled draftsOne documentGrowth and aboveCurrent, Studio v4.14.0+
Content ReleasesMany documents togetherCertain Enterprise plansCurrent
Scheduled PublishingOne documentGrowth and aboveDeprecated since October 2025
DIY publishAt and cronAny rule you writeAll plansYour own code
Front-end date filterVisibility onlyAll plansYour own code

Plan details change from time to time, so check the Sanity pricing guide and your project's plan before you rely on a feature.

How Scheduling in Sanity Evolved

Tutorials from different years show different things, so the history helps:

  1. The @sanity/scheduled-publishing plugin added a Schedule action to documents and a Schedules tool that listed upcoming publishes. It used Sanity's Scheduling API.
  2. Built into the Studio: the plugin was later moved into the core sanity package, enabled with a top-level scheduledPublishing: { enabled: true } setting in sanity.config.ts.
  3. Content Releases arrived, built on version documents, so a group of changes can be previewed and published as one.
  4. Scheduled drafts launched in Studio v4.14.0 in November 2025. Each scheduled draft is a single-document release, so it uses the same release machinery.

Scheduled Publishing was deprecated in October 2025 in favour of scheduled drafts and Content Releases. If your Studio still shows a Schedules tool, you're on the old system.

Scheduled Drafts

A scheduled draft locks a document and publishes it at a future date and time. Editors open the document, choose to schedule it from the publish menu, pick a time, and the document appears in the releases overview so other editors can see what's coming.

A few rules to know:

  • One document per scheduled draft: the Studio won't let you add a second document. Use a release for that.
  • One scheduled draft per document: you can't schedule the same document twice at once.
  • Locked while scheduled: in recent Studio versions, editing a scheduled draft pauses its schedule, and paused drafts appear under their own tab until you reschedule them.
  • Publish permission required: a role that can't publish a type can't schedule it either. Contributors, for example, can't schedule.
  • Published references only: a strong reference to a document that isn't published yet fails validation with "Referenced document must be published", and scheduling is disabled. Publish the referenced document first, make the reference weak, or use a release so both go live together.

Configuring Scheduled Drafts

Scheduled drafts are on by default where your plan includes them. To turn them off for the whole Studio:

// sanity.config.ts
import { defineConfig } from 'sanity'

export default defineConfig({
  // ...
  scheduledDrafts: {
    enabled: false,
  },
})

More often you'll want to limit who sees the schedule action. Its display name is SchedulePublishAction, so you can filter it in the document actions resolver:

// sanity.config.ts
document: {
  actions: (prev, context) =>
    prev.filter(
      (action) =>
        action.displayName !== 'SchedulePublishAction' ||
        context.currentUser?.roles.some((role) => role.name === 'administrator'),
    ),
},

The same pattern can hide it on singletons or settings documents by checking context.schemaType. The document actions guide explains the resolver in detail.

Creating Scheduled Drafts With the API

You can create scheduled drafts from code, for example when importing a content calendar. You need API version 2025-02-19 or later and @sanity/client v7.9.0 or later. A scheduled draft is a release with cardinality: 'one':

// scripts/scheduleDraft.ts
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: '<project-id>',
  dataset: 'production',
  apiVersion: '2025-02-19',
  token: process.env.SANITY_API_WRITE_TOKEN,
  useCdn: false,
})

export async function scheduleDraft(documentId: string, publishAt: Date) {
  const { releaseId } = await client.releases.create({
    metadata: {
      title: `Scheduled: ${documentId}`,
      releaseType: 'scheduled',
      cardinality: 'one',
      intendedPublishAt: publishAt.toISOString(),
    },
  })

  await client.createVersion({
    publishedId: documentId,
    baseId: `drafts.${documentId}`,
    releaseId,
  })

  await client.releases.schedule({ releaseId, publishAt: publishAt.toISOString() })

  return releaseId
}

Each scheduled draft costs three write requests. The API allows 25 mutations per second per IP, and the client doesn't retry 429 responses, so queue bulk jobs or use one release when documents share a time.

To list what's scheduled, query releases with GROQ:

releases::all()[metadata.cardinality == "one" && state == "scheduled"]{
  "scheduledDraftDocs": *[
    sanity::partOfRelease(string::split(^._id, ".")[2])
  ]
}.scheduledDraftDocs[]

Release IDs look like _.releases.<name>, which is why the query splits the ID.

Content Releases

A Content Release groups changes to many documents. A product launch might need a new landing page, three articles, updated navigation and a price change, all live at 9am. Each document in the release gets a version document with an ID like versions.<releaseName>.<documentId>, separate from its normal draft.

Releases come in three types:

Release typeUse it forHow it publishes
ASAPChanges that go live once readySomeone runs the release
At timeA planned date and timeScheduled for that time
UndecidedWork in progress without a datePublish and schedule are tucked under "More options"

Setting a release time doesn't schedule it on its own. You also need to select Schedule release and confirm. Once scheduled, the release's documents are locked; unschedule it to make changes.

Under the hood, a release moves from active to scheduled to published (or archived). If a publish fails, it returns to active with an error property explaining why.

Releases are also the most reliable way to schedule documents that reference each other. A version can strongly reference another document that has a version in the same release, because both are published together. Building editorial workflows in Sanity shows how to restrict scheduling to approved content.

Migrating From Scheduled Publishing

If your Studio uses the old feature, plan the move rather than running both systems side by side:

  1. List existing schedules: open the Schedules tool and note every upcoming publish. Schedules are stored outside your dataset, so you can't fetch them with GROQ.
  2. Recreate them: schedule each document again as a scheduled draft, or group related ones into a release.
  3. Remove the old setup: if you still have the plugin, run npm uninstall @sanity/scheduled-publishing and remove scheduledPublishing() from plugins. If you use the core version, remove the top-level scheduledPublishing setting.
  4. Clean up custom code: remove imports of ScheduleAction and ScheduledBadge from your action and badge resolvers.
  5. Brief editors: the controls move from a Schedules tool into the publish menu and the releases overview.

Note that the old schedules published documents even when they had validation errors, so check those documents as you move them.

Building Your Own Scheduler

On the Free plan, or when you need rules Sanity's features don't cover, such as "publish at the next 9am in the author's time zone", build it yourself. The idea is a publishAt field on the draft and a job that publishes due drafts every few minutes.

Step 1: Add a publishAt Field

// schemaTypes/post.ts
defineField({
  name: 'publishAt',
  title: 'Publish at',
  type: 'datetime',
  description: 'Leave empty to publish manually. The draft is published automatically at this time.',
  validation: (rule) =>
    rule.custom((value) =>
      value && new Date(value) < new Date() ? 'Choose a time in the future' : true,
    ).warning(),
}),

Keep it separate from a publishedAt display date. One says when the job should act; the other is what readers see.

Step 2: Publish Due Drafts

The job finds drafts with a publishAt in the past and publishes each one with the Actions API, which is the same publish operation the Studio uses:

// src/app/api/publish-scheduled/route.ts
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: 'production',
  apiVersion: '2025-02-19',
  token: process.env.SANITY_API_WRITE_TOKEN,
  useCdn: false,
  perspective: 'raw',
})

const DUE_QUERY = `*[
  _id in path("drafts.**")
  && defined(publishAt)
  && publishAt <= now()
  && defined(title) && defined(slug.current)
]{ _id, _rev }`

export async function GET(request: Request) {
  if (request.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 })
  }

  const due = await client.fetch<{ _id: string; _rev: string }[]>(DUE_QUERY)
  const published: string[] = []

  for (const draft of due) {
    const publishedId = draft._id.replace(/^drafts\./, '')
    await client.action({
      actionType: 'sanity.action.document.publish',
      draftId: draft._id,
      publishedId,
      ifDraftRevisionId: draft._rev,
    })
    published.push(publishedId)
  }

  return Response.json({ published })
}

perspective: 'raw' returns drafts as separate documents, so you can filter on drafts. IDs. ifDraftRevisionId makes the publish fail if someone edited the draft after the query ran. The Studio's validation rules don't run here, so the query checks required fields itself, and the write token must stay server-side. Once published, the draft no longer exists, so it won't be picked up again. The mutation API guide covers patches and transactions if you also want to clear publishAt or write a log.

Step 3: Run It on a Schedule

Any scheduler that can call a URL works. On Vercel, add a cron job:

{
  "crons": [{ "path": "/api/publish-scheduled", "schedule": "*/5 * * * *" }]
}

Vercel sends the CRON_SECRET environment variable as a bearer token, which the route checks. The Hobby plan only runs cron jobs once a day, so use GitHub Actions on a schedule, a Cloudflare Worker cron trigger or a paid plan if you need more. Publish times are accurate to the job's interval.

Handling Scheduled Content on the Front End

Publishing in Sanity is only half the job. The page has to update too.

Revalidate on Publish

Whichever method publishes the document, the Content Lake treats it like any other publish, so a webhook fires. Use it to revalidate cached pages:

// src/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { parseBody } from 'next-sanity/webhook'
import type { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const { isValidSignature, body } = await parseBody<{ _type: string }>(
    request,
    process.env.SANITY_REVALIDATE_SECRET,
  )

  if (!isValidSignature || !body?._type) {
    return new Response('Invalid request', { status: 401 })
  }

  revalidateTag(body._type, 'max')
  return Response.json({ revalidated: body._type })
}

Without this, a 9am launch might not appear until the next deploy or the next time-based revalidation. Static sites need a rebuild instead; triggering rebuilds with Sanity webhooks covers Netlify, Vercel and Cloudflare.

Filtering by Date Instead

The simplest approach of all doesn't publish on a schedule: publish now and hide the content until its date.

*[_type == "post" && defined(slug.current) && publishedAt <= now()]
  | order(publishedAt desc)

This works on any plan, but be aware of two catches. The document is already public in the Content Lake, so anyone querying your dataset directly can see it early; don't use this for embargoed news. And no webhook fires when the date passes, so cached pages need a time-based revalidate to pick it up.

Previewing a Release

Editors want to see scheduled content in context before it goes live. Pass a release name as the perspective, together with drafts and published content, in your draft mode client:

const releaseClient = client.withConfig({
  perspective: ['rLaunch2026', 'drafts'],
  useCdn: false,
  token: process.env.SANITY_API_READ_TOKEN,
})

Layers are applied from left to right, with published content always added at the end. In practice, the Presentation tool handles this for you when an editor picks a release in the Studio.


FAQ: Scheduling Content in Sanity

Yes. Scheduled Publishing was deprecated in October 2025. Sanity recommends scheduled drafts for single documents and Content Releases for coordinated changes across several documents. Remove the plugin or the scheduledPublishing setting once you've recreated any upcoming schedules.

Not with the built-in features, which start on the Growth plan. On the Free plan, add a publishAt field and run a cron job that publishes due drafts through the Actions API, or publish immediately and filter by date on the front end.

A scheduled draft publishes one document at a set time and is available from the Growth plan. A Content Release groups changes to many documents so they can be previewed and published together, and is available on certain Enterprise plans. A scheduled draft is technically a release with a single document.

A strong reference must point to a published document, so scheduling is disabled while the author is unpublished. Publish the author first, make the reference weak, or put both documents in the same Content Release so they go live together.

Usually because of caching. The document published, but the page is still cached. Set up a Sanity webhook that revalidates the affected cache tags or triggers a rebuild on every publish, and scheduled content will appear within moments.

Yes, in recent Studio versions. Editing a scheduled draft pauses its schedule, and you reschedule it when the changes are ready. Documents in a scheduled Content Release are locked until you unschedule the release.


Conclusion

Scheduling in Sanity has settled on two features. Scheduled drafts cover the everyday case of publishing one document later, and Content Releases handle launches where many changes must land together. The older Scheduled Publishing feature is deprecated, so if your Studio still has a Schedules tool, recreate the upcoming schedules and remove it.

When the built-in features don't fit your plan or your rules, a publishAt field, a protected route and a cron job give you a reliable scheduler in under a hundred lines. Whichever route you take, connect publishing to your cache with a webhook, so content appears on the site at the time your editors chose, not whenever the next deploy happens to run.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper