How I build a custom Sanity document actions publishing workflow
Jun 08, 2026 · 7 min read
Sanity's default publish button works fine for simple sites. The moment you have a team — editors, legal reviewers, marketing leads — the default action becomes a liability. Anyone can publish anything, instantly, with no record of who approved what. Custom Sanity document actions are how you fix that.
This post covers three patterns I've shipped on real projects: an approval gate that blocks publish until a reviewer signs off, a Slack notification on publish, and a scheduled release trigger using the Sanity Releases API. All three share the same plugin shape, so once you understand one, the others fall into place.
How document actions work in Sanity Studio
Document actions are React components returned from a plugin that plugs into config.document.actions. Each action receives a DocumentActionProps object — document ID, type, current draft, published state — and returns an object describing a button label, optional dialog, and onHandle callback.
You register them in sanity.config.ts:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { approvalAction } from './plugins/approvalAction'
import { slackPublishAction } from './plugins/slackPublishAction'
export default defineConfig({
// ...projectId, dataset, schema
document: {
actions: (prev, context) => {
if (context.schemaType !== 'article') return prev
// Replace the default PublishAction with your own, keep the rest
return prev.map((action) =>
action.action === 'publish' ? approvalAction : action
).concat(slackPublishAction)
},
},
})The prev array contains Sanity's built-in actions — publish, unpublish, delete, duplicate. You can replace, wrap, or append.
Pattern 1: approval gate before publish
The goal: editors click "Request approval", which sets a metadata field on the document. Only users in a reviewer role see the real publish button — everyone else sees a disabled state.
I store approval state in the document itself using a _approvalStatus field (a string field hidden from the default form via hidden: () => true). The action reads it from the current draft, then either shows "Request approval" or the real publish trigger.
// plugins/approvalAction.ts
import { useCurrentUser, useDocumentOperation } from 'sanity'
import type { DocumentActionComponent, DocumentActionProps } from 'sanity'
export const approvalAction: DocumentActionComponent = (
props: DocumentActionProps
) => {
const { id, type, draft, published } = props
const { patch, publish } = useDocumentOperation(id, type)
const user = useCurrentUser()
const isReviewer = user?.roles?.some((r) => r.name === 'reviewer')
const approvalStatus = (draft ?? published)?._approvalStatus as
| string
| undefined
const isApproved = approvalStatus === 'approved'
if (isReviewer && isApproved) {
return {
label: 'Publish (approved)',
tone: 'positive',
onHandle: () => {
patch.execute([{ set: { _approvalStatus: 'published' } }])
publish.execute()
props.onComplete()
},
}
}
if (isReviewer && !isApproved) {
return {
label: 'Approve',
tone: 'caution',
onHandle: () => {
patch.execute([{ set: { _approvalStatus: 'approved' } }])
props.onComplete()
},
}
}
// Editor path
return {
label: approvalStatus === 'approved' ? 'Awaiting publish' : 'Request approval',
disabled: approvalStatus === 'approved',
tone: 'default',
onHandle: () => {
patch.execute([{ set: { _approvalStatus: 'pending' } }])
props.onComplete()
},
}
}A few things worth noting. useDocumentOperation gives you patch and publish as separate operations — you can sequence them. useCurrentUser is safe to call in a document action because Studio already hydrates auth before actions render. The roles array reflects the roles you've configured in manage.sanity.io.
Pattern 2: Slack notification on publish
I do this via a thin Sanity Studio action that calls a Next.js route handler (or any HTTPS endpoint) right after publish.execute(). The route handler sends to Slack's incoming webhook URL stored in an environment variable, so the token never lives in the Studio bundle.
The action itself is simple — wrap the existing PublishAction rather than replace it:
// plugins/slackPublishAction.ts
import { useDocumentOperation, useClient } from 'sanity'
import type { DocumentActionComponent, DocumentActionProps } from 'sanity'
export const slackPublishAction: DocumentActionComponent = (
props: DocumentActionProps
) => {
const { id, type, draft } = props
const { publish } = useDocumentOperation(id, type)
return {
label: 'Publish + notify Slack',
tone: 'positive',
disabled: publish.disabled,
onHandle: async () => {
publish.execute()
try {
await fetch('/api/notify-publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId: id,
type,
title: (draft as { title?: string } | null)?.title ?? 'Untitled',
}),
})
} catch (err) {
console.error('Slack notify failed', err)
}
props.onComplete()
},
}
}The /api/notify-publish route handler on the Next.js side validates a shared secret in the Authorization header, then calls Slack's webhook. Keeping the webhook URL server-side means it doesn't leak through the Studio bundle, which is public.
Pattern 3: scheduled release via the Sanity Releases API
Sanity's content releases API (available on Growth and Enterprise plans as of 2026) lets you bundle documents into a named release and schedule it for a future publish date. You can trigger this from a custom action rather than the Releases panel.
The key call is POST /v2021-06-07/releases to create a release, then PUT the document into it via the releases mutation API. I expose a dialog in the action to let the editor pick a date:
// plugins/scheduleReleaseAction.ts
import { useState } from 'react'
import { useClient } from 'sanity'
import type { DocumentActionComponent, DocumentActionProps } from 'sanity'
export const scheduleReleaseAction: DocumentActionComponent = (
props: DocumentActionProps
) => {
const { id, type, draft } = props
const client = useClient({ apiVersion: '2024-05-23' })
const [dialogOpen, setDialogOpen] = useState(false)
const [publishAt, setPublishAt] = useState('')
return {
label: 'Schedule release',
tone: 'default',
dialog: dialogOpen && {
type: 'dialog',
header: 'Schedule this document',
content: (
<div style={{ padding: '1rem' }}>
<input
type="datetime-local"
value={publishAt}
onChange={(e) => setPublishAt(e.target.value)}
/>
<button
onClick={async () => {
const releaseId = `scheduled-${id}-${Date.now()}`
// 1. Create the release
await client.request({
url: `/releases`,
method: 'PUT',
body: {
id: releaseId,
metadata: {
title: `Scheduled: ${(draft as { title?: string } | null)?.title ?? id}`,
releaseType: 'scheduled',
intendedPublishAt: new Date(publishAt).toISOString(),
},
},
})
// 2. Add the document to the release
await client.request({
url: `/data/mutate/${client.config().dataset}`,
method: 'POST',
body: {
mutations: [{
createOrReplace: {
...(draft as object),
_id: `versions.${releaseId}.${id}`,
},
}],
},
})
setDialogOpen(false)
props.onComplete()
}}
>
Schedule
</button>
</div>
),
},
onHandle: () => setDialogOpen(true),
}
}The versions.{releaseId}.{documentId} ID convention is how Sanity's releases system identifies document versions — using the wrong prefix will silently create a loose document instead of a release member, so get that string right.
Wiring up roles and access control
Custom actions are only as secure as your Sanity role configuration. An action can check useCurrentUser client-side, but a determined editor can still call the Sanity API directly. For hard enforcement — especially on the approval gate — back it up with a Sanity access control rule or a webhook that validates _approvalStatus before indexing in your front-end.
For most agency projects the client-side check is good enough: editors aren't adversarial, they just need guardrails. For regulated industries (legal, pharma, finance), add a server-side check in your revalidation webhook that refuses to push a document live if _approvalStatus !== 'approved'.
What I'd change in hindsight
The approval status field living inside the document schema pollutes your content model. A cleaner approach — which Sanity's own review features lean toward — is a separate reviewRequest document that references the content document. That keeps editorial state out of content state. I've done it both ways and the separate document model wins for anything beyond a two-person team.
Related posts
All posts →How I wire Mux video into a Next.js + Sanity CMS upload workflow
Jun 03, 2026 · 6 min read
Step-by-step: install the Sanity Mux plugin, handle direct uploads, serve signed playback URLs, and generate poster images for fast LCP.
How I wire Mux video into a Next.js + Sanity CMS upload workflow
May 26, 2026 · 6 min read
Step-by-step guide to Mux video in Next.js and Sanity CMS: plugin setup, direct uploads, signed playback URLs, and poster images for fast LCP.
Signs your WordPress site needs a headless CMS rebuild
May 15, 2026 · 5 min read
Slow pages, locked content, and plugin chaos are warning signs your WordPress site has hit its ceiling. Here's how to know when a headless rebuild makes sense.