How to extend default Publish action while preserving validation and functionality?

16 replies
Last updated: Jan 16, 2026
I’m really struggling with how to add extra operations to the Publish action while preserving it’s original functionality (field validating, etc). Is there a more general explanation for the default publish action? I can see the code for it but a) it’s in typescript which adds more confusion when trying to learn/port it b) is rather complex and isn’t as readable as I’d like (compounded by the use of react hooks which results in a lot of things flying around all at once). I’m convinced I’m missing something - I’ve seen all the guides on the document actions API, example code on setting a field at publish time, etc but none of them explain the higher concepts in a way that informs me on how I should be extending existing functionality. I’ve rewritten this multiple times but always end up with a completely broken mess. Am I the only one trying to do these kinds of things unsuccessfully? Also has anyone ever created unit tests for these? I can’t fathom how one can be certain it works as expected without them.. thanks!
AI Update

I completely understand your frustration - you're definitely not alone in finding the Document Actions API challenging to work with! The conceptual gap between "here's the API" and "here's how to actually extend default behavior safely" is real. Let me break down the key concepts that should help you wrap the default publish action successfully.

The Core Pattern: Wrapping, Not Replacing

The key insight is that you want to wrap the default publish action, not recreate it. The problem is that the default publish action is a function component that returns an action object, and you need to preserve its entire implementation while adding your own logic.

Based on the useDocumentOperation hook documentation and patterns from the community, here's the conceptual approach:

// In your sanity.config.ts
import {defineConfig} from 'sanity'
import defaultResolve, {PublishAction} from 'sanity'

export default defineConfig({
  // ... other config
  document: {
    actions: (prev, context) => {
      // Replace the default PublishAction with your wrapped version
      return prev.map((Action) =>
        Action === PublishAction ? MyCustomPublishAction : Action
      )
    }
  }
})

What the Default Publish Action Actually Does

The default publish action handles several critical things that you don't want to reimplement:

  1. Validation checking - It uses the disabled property to prevent publishing when validation fails
  2. Draft management - It knows how to handle the drafts. prefix and the relationship between draft and published documents
  3. Document operations - It calls useDocumentOperation properly under the hood
  4. UI state - It manages loading states, disabled states, and provides user feedback
  5. Error handling - It handles edge cases you might not think of

When you see publish.disabled in action code, that's checking validation state. When you see publish.execute(), that's the actual operation being performed.

A Complete Working Example

Here's a pattern that adds a field update at publish time while preserving all default behavior:

import {useState, useEffect} from 'react'
import {useDocumentOperation} from 'sanity'

export function SetPublishDateAction(props) {
  const {patch, publish} = useDocumentOperation(props.id, props.type)
  const [isPublishing, setIsPublishing] = useState(false)
  
  useEffect(() => {
    // Reset state when publish completes (draft becomes null)
    if (isPublishing && !props.draft) {
      setIsPublishing(false)
    }
  }, [props.draft, isPublishing])
  
  return {
    // CRITICAL: Preserve the disabled state from the default action
    // This maintains validation behavior
    disabled: publish.disabled,
    label: isPublishing ? 'Publishing…' : 'Publish',
    onHandle: async () => {
      setIsPublishing(true)
      
      // Your custom logic: update a field before publishing
      patch.execute([{set: {publishedAt: new Date().toISOString()}}])
      
      // Execute the actual publish operation
      // This handles validation, draft management, etc.
      publish.execute()
      
      // Signal completion to the Studio
      props.onComplete()
    }
  }
}

// In sanity.config.ts
import defaultResolve, {PublishAction} from 'sanity'
import {SetPublishDateAction} from './actions/SetPublishDateAction'

export default defineConfig({
  // ...
  document: {
    actions: (prev, context) => {
      return prev.map((Action) =>
        Action === PublishAction ? SetPublishDateAction : Action
      )
    }
  }
})

The Key Concepts You Need to Understand

  1. useDocumentOperation gives you the primitives - The publish object from this hook has both publish.disabled (validation state) and publish.execute() (the operation itself)

  2. disabled: publish.disabled is crucial - This preserves all the validation logic. If validation fails, the button is disabled automatically

  3. publish.execute() does the heavy lifting - This handles the actual draft-to-published transition, all the edge cases, and the complexity you see in the source code

  4. You're adding logic around the operation, not reimplementing it - Think of it as decorating the action, not replacing it

Common Pitfalls to Avoid

  1. Don't try to recreate the publish logic from scratch - You'll miss validation, draft handling, and edge cases
  2. Always use publish.disabled - This is how validation state flows through
  3. Don't override disabled with your own logic unless you have a very specific reason
  4. Call props.onComplete() - This signals the Studio that the action finished
  5. Handle the isPublishing state properly - Watch props.draft to know when publishing completes

Alternative: Sanity Functions for Post-Publish Actions

For many use cases (sending notifications, syncing to external systems, triggering webhooks), Sanity Functions are actually a better and more modern approach than extending document actions. They can listen to document changes without you having to touch the publish action at all:

// sanity.blueprint.ts
export default {
  functions: [
    {
      name: 'on-publish',
      handler: async (event) => {
        if (event.type === 'document.published') {
          // Your post-publish logic here
          // Send notifications, update external systems, etc.
          console.log('Document published:', event.document)
        }
      }
    }
  ]
}

This is cleaner, more maintainable, and doesn't risk breaking validation or other Studio behavior. Functions are the modern, recommended approach for reacting to content changes - they're serverless, automatically scaled, and completely separate from your Studio UI logic.

On Testing

You're absolutely right to be concerned about testing! Document actions are notoriously hard to test because they're so tightly coupled to Studio's React context. Most developers:

  1. Extract business logic into separate, testable functions - Keep the action wrapper thin
  2. Test the business logic in isolation - Unit test your field update logic, notification logic, etc.
  3. Do manual QA on the action integration - Test in the Studio itself
  4. Use TypeScript - Catch type errors at compile time

The Studio team knows testing is a pain point - it's one reason why Functions are now recommended for many automation use cases, as they're easier to test in isolation and don't require a running Studio.

The Key Takeaway

Don't reimplement - use the primitives from useDocumentOperation. The publish.disabled and publish.execute() from that hook encapsulate all the complexity you're seeing in the source code. Your custom action should:

  1. Get {patch, publish} from useDocumentOperation
  2. Return an action object with disabled: publish.disabled
  3. In onHandle, do your custom logic, then call publish.execute()

You're not missing anything fundamental - the API is genuinely tricky to work with because it's a low-level primitive that needs to handle many edge cases. The documentation could definitely be clearer about the wrapping pattern. But now you know: preserve publish.disabled, call publish.execute(), and let the platform handle the complexity.

Show original thread
16 replies

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?