Type something to search...
Adding Custom Document Actions in Sanity

Adding Custom Document Actions in Sanity

Document actions in Sanity are the buttons in the document editor's footer, such as Publish, Unpublish, Duplicate and Delete. You add your own by writing a document action component, a function that receives the current document's state and returns a description object with a label, an onHandle function and optional extras like an icon, tone or dialog. You then register it in document.actions in sanity.config.ts, either as a static array (which appends it to the defaults) or as a resolver function that receives the existing actions and returns exactly the list you want.

Custom actions are how you turn the Studio into a workflow tool rather than a plain form. You can stamp a field before publishing, ask for confirmation, call an external service, hide Delete from most editors or replace Publish with a version that enforces your own rules. This guide covers the built-in actions, how registration works, writing actions with TypeScript, using document operations, the four dialog types, wrapping built-in actions, conditional actions, ordering and the mistakes that commonly trip people up.

The Built-In Actions

Every Studio ships with a default set of actions. Each built-in action component has an action property that identifies it, which is how you find it when you want to replace or extend it:

Actionaction valueWhat it does
Publish'publish'Copies the draft over the published document
Unpublish'unpublish'Removes the published version and keeps a draft. Shown when viewing the published perspective
Discard changes'discardChanges'Deletes the draft and returns to the published version
Duplicate'duplicate'Creates a copy as a new draft
Delete'delete'Deletes the document and its draft
Restore'restore'Restores an earlier revision from history

There are also release, scheduling and Canvas-related actions in current Studios, so don't assume the list you receive is exactly these six. Always look actions up by their action value rather than by array position.

The footer shows one primary button plus an overflow menu. The first action in the array becomes the primary button, and everything else appears in the menu in order. The Studio walkthrough shows where these appear from an editor's point of view.

How Actions Are Registered

Actions are configured on the document key of your workspace config. There are two forms.

A Static Array

A plain array appends your actions to whatever already exists, including defaults and actions added by plugins:

// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schemaTypes } from './schemaTypes'
import { HelloAction } from './actions/HelloAction'

export default defineConfig({
  name: 'default',
  title: 'Company Website',
  projectId: 'your-project-id',
  dataset: 'production',
  plugins: [structureTool()],
  schema: { types: schemaTypes },
  document: {
    actions: [HelloAction],
  },
})

A Resolver Function

A function gives you full control. It receives the current list (prev) and a context object, and must return the complete list of actions to show:

document: {
  actions: (prev, context) => {
    if (context.schemaType === 'post') {
      return [...prev, HelloAction]
    }
    return prev
  },
},

The context object includes the most useful information for deciding what to show:

PropertyWhat it contains
schemaTypeThe document's type name, such as 'post'
documentIdThe document's ID
currentUserThe logged-in user, including their roles
getClientA function returning a configured Sanity client
schemaThe full schema registry
projectId, datasetThe current project and dataset

With the resolver form, forgetting to return prev removes every built-in action, which is a common surprise the first time.

Writing Your First Action

An action component is a function that receives DocumentActionProps and returns a DocumentActionDescription or null. Type it with DocumentActionComponent from sanity:

// actions/HelloAction.tsx
import type { DocumentActionComponent } from 'sanity'
import { SparklesIcon } from '@sanity/icons'

export const HelloAction: DocumentActionComponent = (props) => {
  const doc = props.draft ?? props.published

  return {
    label: 'Say hello',
    icon: SparklesIcon,
    onHandle: () => {
      window.alert(`Hello from "${doc?.title ?? 'Untitled'}"`)
      props.onComplete()
    },
  }
}

The props tell you everything about the document's current state:

  • id: the published document ID, without the drafts. prefix.
  • type: the schema type name.
  • draft: the draft document, or null if there are no unpublished changes.
  • published: the published document, or null if it has never been published.
  • liveEdit: true if the type skips drafts and publishes every change immediately.
  • onComplete: call this when the action has finished, so the Studio can close menus and reset state.

The description object supports these keys:

KeyPurpose
labelButton or menu text (required)
onHandleRuns when the action is triggered (required)
iconAn icon component, usually from @sanity/icons
toneColour, such as 'primary', 'positive', 'caution' or 'critical'
disabledDisables the button
titleTooltip text, useful for explaining why it's disabled
shortcutA keyboard shortcut in is-hotkey format, such as 'Ctrl+Alt+H'
dialogA dialog to show (see below)
group['default'] for the footer or ['paneActions'] for the document's context menu

Changing the Document With Operations

Most useful actions change the document. The useDocumentOperation hook gives you the same operations the built-in actions use, each with a disabled value and an execute() method:

OperationWhat execute() does
patchApplies patches, such as set or unset, to the draft
publishPublishes the current draft
unpublishUnpublishes the document
discardChangesDiscards the draft
duplicateCreates a copy with a new ID
delDeletes the document
restoreRestores a revision

Here's an action that stamps a publishedAt date the first time a post is published, then publishes it. Keeping your own date field is more reliable than relying on _updatedAt, which changes whenever any script patches the document:

// actions/PublishWithDateAction.tsx
import { useEffect, useState } from 'react'
import { useDocumentOperation, type DocumentActionComponent } from 'sanity'

export const PublishWithDateAction: DocumentActionComponent = (props) => {
  const { patch, publish } = useDocumentOperation(props.id, props.type)
  const [isPublishing, setIsPublishing] = useState(false)

  useEffect(() => {
    // The draft disappears once publishing has finished
    if (isPublishing && !props.draft) {
      setIsPublishing(false)
    }
  }, [isPublishing, props.draft])

  return {
    label: isPublishing ? 'Publishing…' : 'Publish',
    tone: 'positive',
    disabled: Boolean(publish.disabled) || isPublishing,
    onHandle: () => {
      setIsPublishing(true)
      if (!props.draft?.publishedAt && !props.published?.publishedAt) {
        patch.execute([{ set: { publishedAt: new Date().toISOString() } }])
      }
      publish.execute()
      props.onComplete()
    },
  }
}

The only reliable signal that publishing has completed is the draft becoming null, which is why the useEffect watches props.draft. Patches are applied in order, so the date is set before the publish runs.

Adding Dialogs

An action can return a dialog to confirm, inform or collect input. Keep the open state in useState and return the dialog only while it's open. There are four types:

  • confirm: a short message with confirm and cancel buttons, plus a tone.
  • popover: a small popover anchored to the button, closed by clicking outside.
  • dialog: a modal with a header, content and optional footer.
  • custom: renders your own component, for full control over the modal.

A confirm dialog before an irreversible step looks like this:

// actions/ArchiveAction.tsx
import { useState } from 'react'
import { useDocumentOperation, type DocumentActionComponent } from 'sanity'
import { ArchiveIcon } from '@sanity/icons'

export const ArchiveAction: DocumentActionComponent = (props) => {
  const { patch, publish } = useDocumentOperation(props.id, props.type)
  const [open, setOpen] = useState(false)

  return {
    label: 'Archive',
    icon: ArchiveIcon,
    tone: 'caution',
    onHandle: () => setOpen(true),
    dialog: open && {
      type: 'confirm',
      tone: 'caution',
      message: 'Archive this post? It will be hidden from the website.',
      onCancel: () => setOpen(false),
      onConfirm: () => {
        patch.execute([{ set: { status: 'archived' } }])
        publish.execute()
        setOpen(false)
        props.onComplete()
      },
    },
  }
}

For a dialog that collects input, use type: 'dialog' and render form controls from @sanity/ui in content. Keep the input value in local state and only patch the document when the editor confirms.

Wrapping and Replacing Built-In Actions

You rarely need to rebuild Publish from scratch. Instead, wrap the original so you keep its permission checks, validation handling and sync state, and change only what you need.

// actions/wrapPublish.tsx
import type { DocumentActionComponent } from 'sanity'

export function withReviewCheck(original: DocumentActionComponent): DocumentActionComponent {
  const ReviewedPublish: DocumentActionComponent = (props) => {
    const result = original(props)
    if (!result) return null

    const doc = props.draft ?? props.published
    const approved = doc?.reviewStatus === 'approved'

    return {
      ...result,
      disabled: result.disabled || !approved,
      title: approved ? result.title : 'A reviewer must approve this post first',
    }
  }
  ReviewedPublish.action = original.action
  return ReviewedPublish
}

Then swap it in with the resolver form, matching on the action property:

// sanity.config.ts
import { withReviewCheck } from './actions/wrapPublish'

export default defineConfig({
  // ...
  document: {
    actions: (prev, context) =>
      context.schemaType === 'post'
        ? prev.map((action) => (action.action === 'publish' ? withReviewCheck(action) : action))
        : prev,
  },
})

Copying the action property onto the wrapper keeps it identifiable, so other code (or a later resolver) can still find it. You can't reach into the original component's internal state, but you can override anything it returns, including label, icon, tone and onHandle. To run your own logic first, call result.onHandle() at the end of your new onHandle.

Pair a check like this with a field that reviewers set, and a conditional field or validation rule so the rule is visible in the form as well as on the button.

Showing Actions Conditionally

Decide visibility in the resolver whenever the decision depends on type or user. It runs once per document and keeps the components simple:

document: {
  actions: (prev, context) => {
    const isAdmin = context.currentUser?.roles.some((role) => role.name === 'administrator')

    // Only administrators can delete or unpublish settings
    if (context.schemaType === 'siteSettings' && !isAdmin) {
      return prev.filter(
        (action) => action.action !== 'delete' && action.action !== 'unpublish'
      )
    }
    return prev
  },
},

Hiding a button isn't a security boundary. Someone with write access can still change the document through the API, so enforce real restrictions with roles; see roles and permissions. For one-of-a-kind documents, removing Delete and Duplicate is part of the singleton pattern.

When the decision depends on the document's content, return null from the component instead. Because actions are React hooks, call every hook first and return null afterwards, never before a hook:

export const TranslateAction: DocumentActionComponent = (props) => {
  const { patch } = useDocumentOperation(props.id, props.type) // hooks first
  const doc = props.draft ?? props.published
  if (doc?.language === 'en') return null // then decide

  return {
    label: 'Mark for translation',
    onHandle: () => {
      patch.execute([{ set: { translationStatus: 'requested' } }])
      props.onComplete()
    },
  }
}

Ordering and Placement

Actions appear in the order your resolver returns them. To make a custom action the primary button, return it first:

actions: (prev) => [PublishWithDateAction, ...prev.filter((a) => a.action !== 'publish')],

There's no priority or order key, and the split between one primary button and an overflow menu is fixed. A few situations suppress the primary button entirely, such as viewing a historical revision. If a document has a paused scheduled publish, the Schedule action is promoted regardless of order.

The group key changes where an action appears rather than its order. ['paneActions'] puts it in the document's "..." context menu at the top of the pane, which suits utilities like "Copy preview link" that shouldn't sit next to Publish.

Calling External Services

Actions run in the browser, inside the Studio. That's fine for calling the Content Lake with the editor's own permissions, using getClient from the context or the useClient hook:

import { useClient, type DocumentActionComponent } from 'sanity'

export const CountRelatedAction: DocumentActionComponent = (props) => {
  const client = useClient({ apiVersion: '2025-02-19' })

  return {
    label: 'Count references',
    group: ['paneActions'],
    onHandle: async () => {
      const count = await client.fetch<number>(
        'count(*[references($id)])',
        { id: props.id }
      )
      window.alert(`${count} documents reference this one`)
      props.onComplete()
    },
  }
}

Don't put third-party API keys in an action. Environment variables prefixed with SANITY_STUDIO_ are bundled into the Studio's JavaScript and visible to anyone who can load it. Instead, send the request to your own endpoint that holds the secret, or react to the change on the server with webhooks or Sanity Functions. That way the work also happens if content is changed outside the Studio.

Common Mistakes

  • Returning only your action from a resolver: the built-in actions vanish. Spread or map prev.
  • Matching by position: the default list changes between Studio versions. Match on action.action.
  • Calling hooks conditionally: an early return null before useDocumentOperation breaks React's rules of hooks.
  • Forgetting onComplete(): menus and dialogs may not reset properly afterwards.
  • Ignoring disabled: operations report why they can't run, for example there are no changes to publish. Pass that through so the button reflects reality.
  • Relying on the UI for security: anything an action prevents can still be done through the API by someone with permission.

FAQ: Sanity Document Actions

They appear in the document editor's footer. The first action in the list becomes the primary button and the rest go in the overflow menu. If you set the group to paneActions, the action appears in the document's context menu at the top of the pane instead.

Use the resolver form of document.actions. Check context.schemaType and return prev filtered to exclude actions whose action property is delete. Return prev unchanged for every other type.

Yes. Map over the existing actions, find the one whose action property is publish and replace it with a wrapper that calls the original and overrides parts of its result, such as disabled, label or onHandle. This keeps the built-in permission and validation behaviour.

Watch the draft prop. After you call publish.execute(), the draft becomes null once the publish has completed, so a useEffect that checks for this is the most reliable signal.

It can, but the code runs in the editor's browser, so never include secret keys. Call your own server endpoint that holds the secret, or move the work to a webhook or Sanity Function that reacts to the change on the server.

Yes, but liveEdit documents have no drafts, so operations like publish and discardChanges are disabled. Check the liveEdit prop and design your action around patching the published document directly.


Conclusion

Custom document actions are small React hooks that return a description of a button. Register them in document.actions, use the static array form to add and the resolver form to filter, reorder or replace, and lean on useDocumentOperation so your actions change documents the same way the built-in ones do. Dialogs add confirmation and input, and wrapping built-in actions lets you add rules without losing their behaviour.

Start with one action that removes a real friction point for your editors, such as stamping a publish date or requiring approval. Combine it with document badges to show the status your action sets, and with a custom desk structure to group documents by that status, and the Studio starts to reflect how your team actually works.

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