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

16 replies
Last updated: Apr 16, 2021
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
Hey User, based off your last thread, the specific functionality you’re looking for is:-preserve existing document validations
-prompt the user to confirm they want to publish the changes
-set a custom
lastUpdated
field-publish the document
Am I missing anything? We can start breaking this down piece by piece.
yep! one nuance would be
prompt the user to confirm they want to publish the changes
is only when a date field has changed (it drives site URLs). Sorry if I’m being a pest on this, I’ve jsut never run into a problem with Sanity I couldn’t find great docs for so this is really weird! I want to share my result when it works too
Not a pest at all! It’s definitely complex to figure these things out. Let me pass this along to some team members and see if we can get it working. Do you feel comfortable sharing any of your code snippets associated with this?
sure, what would be useful? I have a lot of spaghetti code but I’ll do what I can
How about the document action resolver? Or whatever your
"part:@sanity/base/document-actions/resolver"
points to.
heh alright this is going to be awful, it’s largely based on the built-in publish action
it’s obviously a WIP - I left off at trying to figure out why the doc was POSTing updates on every text input rather than holding off until I hit publish
Awesome, thanks for this. I’ll start asking around and digging in.
I really appreciate it, thank you!
Hi
user S
. Heres an example of just delegating to the built in Publish action, after a confirmation popover

https://gist.github.com/runeb/354d42cb5751c3d1638400be7556f2c2
Followed this bc I'm looking for a similar solution and a note on the gist above -- there's a weird way to get the default PublishAction, but I think you can just import it like this:
import { PublishAction } from 'part:@sanity/base/document-actions';
Thanks
user S
, that is indeed the best way! I have updated the gist
this helped me a lot too, thanks for this. I’m now getting a much cleaner implementation of mashing a couple actions together without demolishing/replicating the existing functionality
here’s what I’ve landed on, with all the inputs I’ve gotten from everyone, which I appreciate greatly!
All this does is watch to see if you’ve edited a special datefield (drives slugs in our blog posts) and if so, prompts to make sure you know what you’re doing. Lastly, it keeps a last updated field up to date. Reason for a last updated field is so we can do API updates to content (such as a data migration that doesn’t change the resulting display content) without all the dates on the site saying everything was just updated.
here’s where I make use of this:

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?