Type something to search...
Building Editorial Workflows in Sanity

Building Editorial Workflows in Sanity

To build an editorial workflow in Sanity, combine the platform's built-in pieces with a little Studio configuration. Give writers the Contributor role so they can edit drafts but not publish, add a workflowStatus field to your documents, and create custom document actions such as "Submit for review" and "Approve" that update it. Then show review queues in the Structure tool, display the status as a document badge, gate the Publish button on approval, and use comments, tasks, scheduled drafts and webhooks for feedback, deadlines, timed publishing and notifications.

Sanity doesn't force a fixed workflow on you. Out of the box, every document has a draft and a published version, and anyone with permission can publish. That suits small teams, but as soon as you have writers, editors and legal reviewers, you need some structure. This guide covers the building blocks Sanity provides, how to implement a draft, review, approve and publish flow in code, and how to add scheduling and notifications on top.

The Building Blocks

Here's what's available and on which plan, based on Sanity's documentation and pricing page at the time of writing:

FeatureWhat it gives youPlan
Drafts and publishingEvery edit is a draft until someone publishesAll plans
Contributor roleCan write drafts but can't publish, enforced by the APIGrowth and Enterprise
Document actions and badgesCustom buttons and status labels in the document toolbarAll plans (Studio code)
CommentsField-level discussion threads with mentionsGrowth and Enterprise
TasksAssignable to-dos with due dates, attached to documentsGrowth and Enterprise
Scheduled draftsSchedule a single document to publish laterGrowth and Enterprise
Content ReleasesGroup and schedule changes across many documentsEnterprise
Custom rolesFine-grained permissions with content filtersEnterprise

Check the Sanity pricing guide before planning around a feature, because the line between plans moves from time to time.

The most important distinction is between real enforcement and Studio conventions. The Contributor role is enforced by the Content Lake: a contributor's request to publish fails, wherever it comes from. A custom status field and a disabled button are conventions. They guide people using Studio, but anyone with Editor rights and an API token could bypass them. Use roles for rules that must hold, and Studio customisation for everything else. Managing user roles and permissions explains the roles in detail.

Designing Your Workflow

Before writing code, agree on the stages with your team. A typical editorial flow looks like this:

  1. Draft: a writer creates and edits the document.
  2. In review: the writer submits it, and an editor reviews it.
  3. Changes requested: the editor sends it back with comments.
  4. Approved: the editor signs it off, ready to publish now or later.
  5. Published: it's live on the site.

Keep the number of stages small. Each extra stage is another place for content to get stuck. If a stage doesn't change who's responsible for the next step, you probably don't need it.

Step 1: Add a Status Field

Store the stage in the document itself so it can be queried, shown in lists and used in webhooks:

// schemaTypes/fields/workflowStatus.ts
import { defineField } from 'sanity';

export const workflowStatuses = [
  { title: 'Draft', value: 'draft' },
  { title: 'In review', value: 'inReview' },
  { title: 'Changes requested', value: 'changesRequested' },
  { title: 'Approved', value: 'approved' },
  { title: 'Published', value: 'published' },
];

export const workflowStatusField = defineField({
  name: 'workflowStatus',
  title: 'Workflow status',
  type: 'string',
  options: { list: workflowStatuses },
  initialValue: 'draft',
  readOnly: true,
});

Add it to each document type that goes through review. Making it readOnly means the only way to change it is through your actions, which keeps the flow predictable. If you use field groups, add a group property to put it in its own group so it doesn't clutter the main form. How to organise fields with groups and fieldsets shows how.

Step 2: Create Workflow Document Actions

Document actions are the buttons in the document footer. Each workflow step becomes an action that patches workflowStatus:

// actions/workflowActions.tsx
import { useDocumentOperation, type DocumentActionComponent } from 'sanity';

function statusOf(props: Parameters<DocumentActionComponent>[0]) {
  return ((props.draft ?? props.published)?.workflowStatus as string | undefined) ?? 'draft';
}

export const submitForReview: DocumentActionComponent = (props) => {
  const { patch } = useDocumentOperation(props.id, props.type);
  const status = statusOf(props);

  if (!['draft', 'changesRequested', 'published'].includes(status) || !props.draft) return null;

  return {
    label: 'Submit for review',
    tone: 'primary',
    onHandle: () => {
      patch.execute([{ set: { workflowStatus: 'inReview' } }]);
      props.onComplete();
    },
  };
};

export const approve: DocumentActionComponent = (props) => {
  const { patch } = useDocumentOperation(props.id, props.type);
  if (statusOf(props) !== 'inReview') return null;

  return {
    label: 'Approve',
    tone: 'positive',
    onHandle: () => {
      patch.execute([{ set: { workflowStatus: 'approved' } }]);
      props.onComplete();
    },
  };
};

export const requestChanges: DocumentActionComponent = (props) => {
  const { patch } = useDocumentOperation(props.id, props.type);
  if (statusOf(props) !== 'inReview') return null;

  return {
    label: 'Request changes',
    tone: 'caution',
    onHandle: () => {
      patch.execute([{ set: { workflowStatus: 'changesRequested' } }]);
      props.onComplete();
    },
  };
};

Note that useDocumentOperation is called before any early return null. Actions are React components, so hooks must run on every render. The published status is included in "Submit for review" so that editing a live document sends it back through review. Adding custom document actions in Sanity covers the actions API in more depth, including dialogs and confirmation steps.

Step 3: Gate the Publish Action

Now make Publish depend on approval. Rather than replacing Sanity's publish logic, wrap the built-in action, disable it until the document is approved, and mark the document as published when it runs:

// actions/gatedPublish.tsx
import { useDocumentOperation, type DocumentActionComponent } from 'sanity';

export function gatedPublish(original: DocumentActionComponent): DocumentActionComponent {
  const GatedPublish: DocumentActionComponent = (props) => {
    const result = original(props);
    const { patch } = useDocumentOperation(props.id, props.type);
    const approved = props.draft?.workflowStatus === 'approved';

    if (!result) return null;

    return {
      ...result,
      disabled: result.disabled || !approved,
      title: approved ? result.title : 'This document must be approved before publishing',
      onHandle: () => {
        patch.execute([{ set: { workflowStatus: 'published' } }]);
        result.onHandle?.();
      },
    };
  };
  return GatedPublish;
}

Register everything in sanity.config.ts, applying the workflow only to the types that need it:

// sanity.config.ts
import { defineConfig } from 'sanity';
import { structureTool } from 'sanity/structure';
import { approve, requestChanges, submitForReview } from './actions/workflowActions';
import { gatedPublish } from './actions/gatedPublish';
import { workflowBadge } from './badges/workflowBadge';
import { structure } from './structure';
import { schemaTypes } from './schemaTypes';

const workflowTypes = ['post', 'page'];

export default defineConfig({
  name: 'default',
  title: 'Editorial',
  projectId: '<project-id>',
  dataset: 'production',
  plugins: [structureTool({ structure })],
  schema: { types: schemaTypes },
  document: {
    actions: (prev, context) => {
      if (!workflowTypes.includes(context.schemaType)) return prev;

      const actions = prev.map((action) => (action.action === 'publish' ? gatedPublish(action) : action));
      return [submitForReview, approve, requestChanges, ...actions];
    },
    badges: (prev, context) =>
      workflowTypes.includes(context.schemaType) ? [workflowBadge, ...prev] : prev,
  },
});

Combine this with roles for real protection. Writers with the Contributor role can submit for review but can never publish, whatever the button says. You can also hide Approve from contributors by checking context.currentUser?.roles in the actions resolver.

Step 4: Show the Status as a Badge

A badge makes the stage visible at a glance in the document header:

// badges/workflowBadge.ts
import type { DocumentBadgeComponent } from 'sanity';

const badges = {
  inReview: { label: 'In review', color: 'warning' },
  changesRequested: { label: 'Changes requested', color: 'danger' },
  approved: { label: 'Approved', color: 'success' },
} as const;

export const workflowBadge: DocumentBadgeComponent = (props) => {
  const status = props.draft?.workflowStatus as keyof typeof badges | undefined;
  return status && badges[status] ? badges[status] : null;
};

Draft and published states already have their own badges, so this one only appears for the review stages. Using document badges and previews in Sanity has more examples.

Step 5: Build Review Queues

Editors shouldn't have to hunt for content waiting on them. Add filtered lists to the Structure tool:

// structure.ts
import type { StructureResolver } from 'sanity/structure';

const byStatus = (S: Parameters<StructureResolver>[0], title: string, status: string) =>
  S.listItem()
    .title(title)
    .child(
      S.documentList()
        .title(title)
        .apiVersion('2025-02-19')
        .filter('_type in ["post", "page"] && workflowStatus == $status')
        .params({ status }),
    );

export const structure: StructureResolver = (S) =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Workflow')
        .child(
          S.list()
            .title('Workflow')
            .items([
              byStatus(S, 'In review', 'inReview'),
              byStatus(S, 'Changes requested', 'changesRequested'),
              byStatus(S, 'Approved, not published', 'approved'),
            ]),
        ),
      S.divider(),
      ...S.documentTypeListItems(),
    ]);

Document lists in Studio show drafts, so a draft in review appears in its queue as soon as the status changes. Set the API version explicitly on lists to 2025-02-19 or later, which keeps them working when an editor switches to a release perspective. How to customise the Sanity Studio desk structure covers the Structure Builder API.

Step 6: Feedback With Comments and Tasks

On Growth and Enterprise plans, two built-in features fill the gaps a status field can't:

  • Comments: reviewers can comment on a specific field, mention colleagues and resolve threads. "Changes requested" plus a few comments on the exact paragraphs is far clearer than an email.
  • Tasks: assign a to-do to a person, with a due date and an optional target document. Open tasks are shown next to the document's publish button, which is a gentle reminder before anything goes live.

Both are enabled by default on eligible plans and stored in a companion dataset, so they don't count towards your document limits. You can turn either off in sanity.config.ts with tasks: { enabled: false }, or by disabling comments for a document type, if your team prefers another tool.

Step 7: Schedule Approved Content

Approval and publishing often happen at different times. Two features handle that:

  • Scheduled drafts (Growth and above): schedule a single document to publish at a set date and time. The document is locked while it's scheduled, and other editors can see it in the releases overview.
  • Content Releases (Enterprise): group changes to many documents, such as a product launch with a landing page, three articles and updated navigation, preview them together and publish them at once.

To keep scheduling inside the workflow, restrict it to approved content the same way you gated Publish. The scheduled drafts action has the display name SchedulePublishAction:

document: {
  actions: (prev, context) =>
    prev.filter(
      (action) =>
        action.displayName !== 'SchedulePublishAction' ||
        context.currentUser?.roles.some((role) => ['administrator', 'editor'].includes(role.name)),
    ),
},

The older Scheduled Publishing plugin is deprecated. If it's still in your Studio, migrate to scheduled drafts or releases so editors don't see two sets of scheduling controls. Scheduling content publishing in Sanity explains both options.

Step 8: Notify People When Status Changes

A workflow only moves if people know it's their turn. A GROQ-powered webhook can post to Slack whenever a document enters review. Create one in your project's API settings with:

  • URL: a Slack incoming webhook URL.
  • Trigger on: update.
  • Drafts: enabled, because review happens on drafts.
  • Filter: _type in ["post", "page"] && workflowStatus == "inReview" && before().workflowStatus != "inReview"
  • Projection: {"text": "Ready for review: " + title}

The before() check makes the webhook fire once, when the status changes, rather than on every keystroke while the document sits in review. Add similar webhooks for "Changes requested" to notify the writer. How to use Sanity webhooks covers filters, projections and security.

When You Need More

The pattern above covers most editorial teams. Beyond it, you have a few options:

  • Custom roles on Enterprise, using content resources to limit who can edit what, for example letting the legal team edit only documents in review.
  • Sanity Workflows, a newer engine for defining stages, activities and transitions in TypeScript with its own Studio plugin. It's in early access at the time of writing, with pre-1.0 APIs that can change between minor releases, so treat it as something to evaluate rather than build a critical process on today.
  • Your own service, listening to webhooks or Sanity Functions, for approvals that span other systems.

FAQ: Editorial Workflows in Sanity

Sanity has the building blocks rather than a fixed workflow: drafts, the Contributor role that can't publish, comments, tasks and scheduling. You implement the approval steps with a status field and custom document actions, which lets you match your team's process exactly.

Give them the Contributor role, available on Growth and Enterprise plans. Contributors can create and edit drafts but can't publish, and this is enforced by the API, not just hidden in Studio. An editor or administrator then publishes approved content.

Yes. Wrap the built-in publish action in the document.actions configuration and return it with disabled set to true unless the document's status is approved. Pair this with roles, because a disabled button doesn't stop someone with publishing rights from using the API.

No. Comments and tasks are available on Growth and Enterprise plans. On the Free plan you can still build a workflow with a status field, document actions and review lists, and use webhooks to notify people.

Scheduled drafts schedule one document to publish at a future time and are available from the Growth plan. Content Releases group changes across many documents so they can be previewed and published together, and are an Enterprise feature.

Create a GROQ-powered webhook with drafts enabled and a filter that uses before() to detect the status changing to in review. Its projection can format a message for a Slack incoming webhook or any other service.


Conclusion

An editorial workflow in Sanity is built from a few small pieces. Roles decide who can publish, a status field records where each document is, custom document actions move it between stages, and a gated Publish action, badges and review queues make the process visible in Studio. Comments, tasks and scheduled drafts handle feedback, deadlines and timing on paid plans.

Start with the simplest flow your team will actually follow, and enforce the rules that matter with roles rather than buttons alone. Once the basics are working, add notifications so nothing sits in review unnoticed, and look at releases or custom roles only when your content operations genuinely need them.

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