Dyrected
Editor ExperiencePublishing and Workflows

Overview

Editorial workflows give a collection review states and a public snapshot, so editing never changes live content until you publish.

Editorial workflows turn a collection into a state machine with review and controlled publishing. A workflow-enabled collection keeps two things for each document: an editable working revision and an independent public snapshot. Editing the working revision never changes what the live API returns until you transition the document to a published state.

Use an editorial workflow when content needs review, staged publishing, or an audit trail of who moved what and when — editorial publishing, approval flows, or any "draft then publish" lifecycle.

Start with drafts: true

If all you need is "edit privately, then publish," you don't have to define a workflow at all. Set drafts: true on the collection and Dyrected wires up a ready-made two-state publishing workflow for you.

import { defineCollection, defineTextField } from "@dyrected/core";

export const Pages = defineCollection({
  slug: "pages",
  drafts: true,
  fields: [
    defineTextField({ name: "title", label: "Title", required: true }),
  ],
});

That single flag gives you:

  • Two states, draft and published, with publish and unpublish transitions — the same machinery the rest of this page describes, just pre-configured.
  • A working draft that never touches live content. While you edit, the public API keeps serving the last published version. New documents start as drafts, so they stay invisible to the public until you publish them.
  • Admin affordances: clear live-vs-draft status in the list and editor, fast transition actions, and workflow details kept as a secondary surface.

Drafts are visible to any signed-in admin user, so editors can see and work on unpublished content in both the list and the editor. The public API only ever returns published content, so drafts never leak to your live site. Publishing is just as open: any signed-in user can publish or unpublish — the simple drafts workflow adds no role gate. If you need role-based review — editors submit, publishers approve, only some roles can publish — reach for a full workflow (below), which gates each transition by capability.

Turning drafts: true on for a collection that already has content? Existing documents are treated as published — they stay live and visible — so enabling it never hides content you already had.

Enabling a full publishing workflow

When you need review steps and role-based control, attach a workflow instead of drafts. Build one with definePublishingWorkflow — it gives you a ready-made three-state editorial flow and maps your app's role names onto it, so it fits whatever you already call your users.

import { defineCollection, defineTextField, defineRichTextField, definePublishingWorkflow } from "@dyrected/core";

export const Posts = defineCollection({
  slug: "posts",
  workflow: definePublishingWorkflow({
    editors: ["writer"],
    publishers: ["managing-editor", "admin"],
  }),
  fields: [
    defineTextField({ name: "title", label: "Title", required: true }),
    defineRichTextField({ name: "body", label: "Body" }),
  ],
});

You get three states — draft → in review → published — and four transitions: submit (draft to review), publish (review to published), reject (review back to draft, with a required comment), and unpublish (published back to draft). The role lists decide who may do what: everyone under editors can edit and submit, and everyone under publishers can also publish and unpublish. Those are the two tiers — put each of your roles in whichever fits.

Already using the role names editor, publisher, and admin? Skip the mapping and call publishingWorkflow() — a zero-config shorthand for definePublishingWorkflow() with exactly those defaults.

When your states or transitions differ too — not just the role names — build your own WorkflowConfig from scratch; the reference at the bottom of this page lists every field.

What you get

  • A working revision editors change freely, plus a public snapshot the live API serves.
  • Named states and transitions, each gated by capabilities and with optional required comments.
  • An atomic transition route and paginated workflow history for every document.
  • Durable lifecycle events you can handle to trigger side effects when documents move between states.

Editing workflow entries in the Admin

Workflow-enabled editing is designed around one rule: saving a draft and changing workflow state are different actions.

  • Saving updates the working revision only.
  • Publishing, unpublishing, submitting for review, rejecting, and every custom transition stay explicit.
  • The public snapshot changes only when a transition moves the document into or out of a published state.

By default, collections that use workflow or drafts: true autosave the working draft in the Admin. This applies to the built-in drafts flow and to custom workflows alike.

If a team needs explicit manual draft saving for a specific collection, disable it in that collection's admin config:

export const Posts = defineCollection({
  slug: "posts",
  workflow: definePublishingWorkflow(),
  admin: {
    autosave: false,
  },
  fields: [
    defineTextField({ name: "title", label: "Title", required: true }),
  ],
});

You can also tune the debounce:

admin: {
  autosaveDelayMs: 2000,
}

The important boundary is that autosave never performs a transition. It only persists the working draft revision. If a document is already live, the previously published snapshot stays on the site until someone explicitly runs a transition such as publish.

Performing a transition

A transition moves a document from one state to the next — submit sends a draft into review, publish takes it live. Every transition runs on the server through a single authenticated route, so the rules you configure are always enforced: a client can't skip a step or publish something its user isn't allowed to.

Call one from the SDK with the document id and the transition name:

// Send a draft for review, then approve and publish it
await client.collection("posts").transition(postId, "submit");
await client.collection("posts").transition(postId, "publish");

Some transitions ask for more. One marked requireComment (like reject) must be given a reason, and any transition can pass an expectedRevision so a concurrent edit doesn't get silently overwritten:

await client.collection("posts").transition(postId, "reject", {
  expectedRevision: post._workflow.revision,
  comment: "Needs a stronger headline before this goes out.",
});

If the document changed since you loaded it, the server rejects the move instead of clobbering the newer revision — so two people editing at once can't lose each other's work. Each successful transition bumps the revision and appends to the document's workflow history.

Who can see drafts, and who can publish

Every editorial setup has to answer two questions: who can see unpublished work, and who can move it forward. A workflow answers both through capabilities mapped from your app's existing user roles.

  • The public never sees drafts. Unauthenticated requests and the live API only ever get the published snapshot; the working revision stays private.
  • Transitions can require capabilities. In the publishing workflow, publish requires the entry.publish capability, so only roles in your publishers tier can take a document live. Ask for a transition you're not allowed to perform and the server answers 403.
  • Roles grant capabilities. definePublishingWorkflow gives the editors tier edit and submit, and the publishers tier edit, submit, publish, and unpublish — matched against the values in each user's roles. You choose which of your own roles fill each tier.

So an editor can draft and submit for review, but only a publisher or admin can publish — enforced on the server, not merely hidden in the UI.

This is the real difference from drafts: true. The simple drafts workflow defines no roles and no required capabilities, so any signed-in user can view drafts and publish. Reach for definePublishingWorkflow the moment you need genuine editorial gating.

Reacting to a transition

When a document moves between states, Dyrected records a durable lifecycle event you can handle to run a side effect — purge a cache, send a notification, kick off a downstream job. For logic that must run inside the transition instead, a workflow can also define beforeTransition and afterTransition hooks. See Lifecycle events for the full list of events, how to register a handler, and the delivery guarantees.

Build your own workflow

The publishing workflow is just one shape. A WorkflowConfig is a plain state machine: you define its states (exactly one marked published: true, the revision the public API serves), the transitions between them (each with its requiredCapabilities and optional requireComment), the roles that map your users to capabilities, and the initialState new documents start in. Everything else on this page — server-enforced transitions, expectedRevision concurrency, workflow history, lifecycle events — works the same for a custom workflow.

For complete, copy-paste recipes — a legal approval chain, maker–checker sign-off, scheduled publishing, translation gating, and a newsroom pipeline — see Custom workflows.

Generated reference

The contracts below are generated from the public @dyrected/core exports by @dyrected/knowledge, so the workflow types, lifecycle events, helpers, and transition contracts stay in sync with the package.

availableWorkflowTransitions

Exported function from @dyrected/core.

export function availableWorkflowTransitions(
  workflow: WorkflowConfig,
  state: string,
  user?: AuthenticatedUser,
): WorkflowTransition[]

canViewWorkflowDraft

Exported function from @dyrected/core.

export function canViewWorkflowDraft(workflow: WorkflowConfig, user?: AuthenticatedUser): boolean

createLifecycleEvent

Exported function from @dyrected/core.

export function createLifecycleEvent(args: {
  name: LifecycleEventName;
  collection: string;
  documentId: string;
  actorId?: string;
  payload: Record<string, unknown>;
}): LifecycleEvent

createWorkflowDocument

Exported function from @dyrected/core.

export async function createWorkflowDocument(args: {
  config: DyrectedConfig;
  collection: CollectionConfig;
  data: Record<string, unknown>;
  user?: AuthenticatedUser;
}): Promise<{ doc: BaseDocument; event: LifecycleEvent }>

definePublishingWorkflow

Exported function from @dyrected/core.

export function definePublishingWorkflow(options: PublishingWorkflowOptions = {}): WorkflowConfig

dispatchLifecycleEvent

Exported function from @dyrected/core.

export async function dispatchLifecycleEvent(config: DyrectedConfig, event: LifecycleEvent): Promise<void>

dispatchPendingLifecycleEvents

Exported function from @dyrected/core.

export async function dispatchPendingLifecycleEvents(config: DyrectedConfig, limit = 50): Promise<number>

initializeWorkflowDocument

Exported function from @dyrected/core.

export function initializeWorkflowDocument(data: Record<string, unknown>, workflow: WorkflowConfig)

LIFECYCLE_EVENTS_COLLECTION

Exported constant from @dyrected/core.

export const LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";

LifecycleEvent

Exported interface from @dyrected/core.

export interface LifecycleEvent<TPayload = Record<string, unknown>> {
  /** Unique id for this event. */
  id: string;
  /** The lifecycle event name, e.g. `"workflow.transitioned"` or `"entry.published"`. */
  name: LifecycleEventName;
  /** Slug of the collection the event is about. */
  collection: string;
  /** Id of the document the event is about. */
  documentId: string;
  /** ISO timestamp of when the event occurred. */
  occurredAt: string;
  /** Id of the user who triggered the event, when known. */
  actorId?: string;
  /** Event-specific data, such as the transition name and affected revision. */
  payload: TPayload;
  /** Number of delivery attempts made so far. */
  attempts: number;
  /** Current delivery status as the dispatcher works through the queue. */
  status: "pending" | "processing" | "delivered" | "failed";
  /** ISO timestamp of the next delivery retry, while pending. */
  nextAttemptAt?: string;
  /** ISO timestamp of successful delivery. */
  deliveredAt?: string;
  /** Message from the most recent failed delivery attempt. */
  lastError?: string;
}
OptionDescription
id (required)Unique id for this event.
name (required)The lifecycle event name, e.g. `"workflow.transitioned"` or `"entry.published"`.
collection (required)Slug of the collection the event is about.
documentId (required)Id of the document the event is about.
occurredAt (required)ISO timestamp of when the event occurred.
actorId (optional)Id of the user who triggered the event, when known.
payload (required)Event-specific data, such as the transition name and affected revision.
attempts (required)Number of delivery attempts made so far.
status (required)Current delivery status as the dispatcher works through the queue.
nextAttemptAt (optional)ISO timestamp of the next delivery retry, while pending.
deliveredAt (optional)ISO timestamp of successful delivery.
lastError (optional)Message from the most recent failed delivery attempt.

LifecycleEventHandler

Exported type from @dyrected/core.

export type LifecycleEventHandler = (
  event: LifecycleEvent,
) => void | Promise<void>;

LifecycleEventName

Exported type from @dyrected/core.

export type LifecycleEventName = (typeof LIFECYCLE_EVENT_NAMES)[number];

materializeWorkflowDocument

Exported function from @dyrected/core.

export function materializeWorkflowDocument(
  doc: BaseDocument,
  workflow: WorkflowConfig,
  user?: AuthenticatedUser,
): BaseDocument | null

publishedStateName

The state a legacy document is treated as: the published one, else the last.

export function publishedStateName(workflow: WorkflowConfig): string

publishingWorkflow

Exported function from @dyrected/core.

export function publishingWorkflow(): WorkflowConfig

PublishingWorkflowOptions

Exported interface from @dyrected/core.

export interface PublishingWorkflowOptions {
  /** Role values allowed to edit and submit for review. Defaults to `["editor"]`. */
  editors?: string[];
  /** Role values allowed to also publish and unpublish. Defaults to `["publisher", "admin"]`. */
  publishers?: string[];
}
OptionDescription
editors (optional)Role values allowed to edit and submit for review. Defaults to `["editor"]`.
publishers (optional)Role values allowed to also publish and unpublish. Defaults to `["publisher", "admin"]`.

saveWorkflowDraft

Exported function from @dyrected/core.

export async function saveWorkflowDraft(args: {
  config: DyrectedConfig;
  collection: CollectionConfig;
  id: string;
  originalDoc: BaseDocument;
  data: Record<string, unknown>;
  user?: AuthenticatedUser;
}): Promise<{ doc: BaseDocument; event: LifecycleEvent }>

simplePublishingWorkflow

Exported function from @dyrected/core.

export function simplePublishingWorkflow(): WorkflowConfig

transitionWorkflow

Exported function from @dyrected/core.

export async function transitionWorkflow(args: {
  config: DyrectedConfig;
  collection: CollectionConfig;
  id: string;
  transitionName: string;
  expectedRevision?: number;
  comment?: string;
  user?: AuthenticatedUser;
  req: HookRequestContext;
}): Promise<BaseDocument>

WORKFLOW_HISTORY_COLLECTION

Exported constant from @dyrected/core.

export const WORKFLOW_HISTORY_COLLECTION = "__workflow_history";

workflowCapabilities

Exported function from @dyrected/core.

export function workflowCapabilities(workflow: WorkflowConfig, user?: AuthenticatedUser): Set<string>

WorkflowConfig

Configuration for a collection's editorial workflow: the states a document can be in, the transitions between them, and the roles allowed to perform each. Attach it to a collection's workflow, or build one with definePublishingWorkflow.

export interface WorkflowConfig<TDoc extends object = Record<string, unknown>> {
  /** The state every new document starts in, e.g. `"draft"`. */
  initialState: string;
  /** State used for a new working revision created from published content. */
  draftState?: string;
  /** All states a document can occupy. Exactly one should be marked `published`. */
  states: WorkflowState[];
  /** The allowed moves between states, each with its own capability and comment rules. */
  transitions: WorkflowTransition[];
  /** Maps values in `user.roles` to workflow capabilities. */
  roles?: WorkflowRole[];
  /** Server-side hooks that run around every transition. */
  hooks?: {
    /** Runs before a transition commits — throw to validate or block the move. */
    beforeTransition?: CollectionBeforeTransitionHook<TDoc>[];
    /** Runs after a transition commits — trigger notifications or downstream work. */
    afterTransition?: CollectionAfterTransitionHook<TDoc>[];
  };
}
OptionDescription
initialState (required)The state every new document starts in, e.g. `"draft"`.
draftState (optional)State used for a new working revision created from published content.
states (required)All states a document can occupy. Exactly one should be marked `published`.
transitions (required)The allowed moves between states, each with its own capability and comment rules.
roles (optional)Maps values in `user.roles` to workflow capabilities.
hooks (optional)Server-side hooks that run around every transition.

WorkflowMetadata

Exported interface from @dyrected/core.

export interface WorkflowMetadata {
  /** The document's current workflow state, e.g. `"draft"` or `"published"`. */
  state: string;
  /** Revision counter, incremented on every committed transition. */
  revision: number;
  /** Revision number currently exposed as the public snapshot, once published. */
  publishedRevision?: number;
  /** ISO timestamp of the most recent publish. */
  publishedAt?: string;
  /** Id of the user who last published this document. */
  publishedBy?: string;
  /** Transitions currently allowed for the requesting user. Response-only. */
  availableTransitions?: string[];
}
OptionDescription
state (required)The document's current workflow state, e.g. `"draft"` or `"published"`.
revision (required)Revision counter, incremented on every committed transition.
publishedRevision (optional)Revision number currently exposed as the public snapshot, once published.
publishedAt (optional)ISO timestamp of the most recent publish.
publishedBy (optional)Id of the user who last published this document.
availableTransitions (optional)Transitions currently allowed for the requesting user. Response-only.

WorkflowRole

Exported interface from @dyrected/core.

export interface WorkflowRole {
  /** Existing user role value, for example `editor` or `publisher`. */
  role: string;
  capabilities: string[];
}
OptionDescription
role (required)Existing user role value, for example `editor` or `publisher`.
capabilities (required)

WorkflowState

Exported interface from @dyrected/core.

export interface WorkflowState {
  /** Stable machine-readable state key. */
  name: string;
  /** Label rendered in the Admin UI. */
  label: string;
  /** Marks the state whose revision is visible to public readers. */
  published?: boolean;
  /** Optional visual tone used by the Admin UI. */
  color?: "neutral" | "warning" | "success" | "danger" | "info";
}
OptionDescription
name (required)Stable machine-readable state key.
label (required)Label rendered in the Admin UI.
published (optional)Marks the state whose revision is visible to public readers.
color (optional)Optional visual tone used by the Admin UI.

WorkflowTransition

Exported interface from @dyrected/core.

export interface WorkflowTransition {
  /** Stable transition key used by the REST and SDK APIs. */
  name: string;
  label: string;
  from: string | string[];
  to: string;
  /** Every listed capability is required. */
  requiredCapabilities?: string[];
  /** Require a non-empty comment when performing the transition. */
  requireComment?: boolean;
  /** Remove the public snapshot after this transition commits. */
  unpublish?: boolean;
}
OptionDescription
name (required)Stable transition key used by the REST and SDK APIs.
label (required)
from (required)
to (required)
requiredCapabilities (optional)Every listed capability is required.
requireComment (optional)Require a non-empty comment when performing the transition.
unpublish (optional)Remove the public snapshot after this transition commits.

WorkflowTransitionContext

Exported interface from @dyrected/core.

export interface WorkflowTransitionContext<
  TDoc extends object = Record<string, unknown>,
> {
  /** The transition being performed. */
  transition: WorkflowTransition;
  /** State the document is moving out of. */
  from: string;
  /** State the document is moving into. */
  to: string;
  /** The document as it stands before the transition commits. */
  doc: TDoc;
  /** The user performing the transition, when the request is authenticated. */
  user?: AuthenticatedUser;
  /** Comment supplied with the transition; required when `requireComment` is set. */
  comment?: string;
  /** Request context for the transition. */
  req: HookRequestContext;
  /** Transaction-scoped database adapter for reads and writes inside the hook. */
  db: DatabaseAdapter;
}
OptionDescription
transition (required)The transition being performed.
from (required)State the document is moving out of.
to (required)State the document is moving into.
doc (required)The document as it stands before the transition commits.
user (optional)The user performing the transition, when the request is authenticated.
comment (optional)Comment supplied with the transition; required when `requireComment` is set.
req (required)Request context for the transition.
db (required)Transaction-scoped database adapter for reads and writes inside the hook.

On this page

Dyrected| Cloud

Get your backend ready in minutes

Use a managed database, storage, APIs, and admin dashboard without setting up the infrastructure yourself.

Set Up My Backend