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 →