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,
draftandpublished, withpublishandunpublishtransitions — 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,
publishrequires theentry.publishcapability, so only roles in yourpublisherstier can take a document live. Ask for a transition you're not allowed to perform and the server answers403. - Roles grant capabilities.
definePublishingWorkflowgives theeditorstier edit and submit, and thepublisherstier edit, submit, publish, and unpublish — matched against the values in each user'sroles. 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): booleancreateLifecycleEvent
Exported function from @dyrected/core.
export function createLifecycleEvent(args: {
name: LifecycleEventName;
collection: string;
documentId: string;
actorId?: string;
payload: Record<string, unknown>;
}): LifecycleEventcreateWorkflowDocument
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 = {}): WorkflowConfigdispatchLifecycleEvent
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;
}| Option | Description |
|---|---|
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 | nullpublishedStateName
The state a legacy document is treated as: the published one, else the last.
export function publishedStateName(workflow: WorkflowConfig): stringpublishingWorkflow
Exported function from @dyrected/core.
export function publishingWorkflow(): WorkflowConfigPublishingWorkflowOptions
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[];
}| Option | Description |
|---|---|
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(): WorkflowConfigtransitionWorkflow
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>[];
};
}| Option | Description |
|---|---|
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[];
}| Option | Description |
|---|---|
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[];
}| Option | Description |
|---|---|
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";
}| Option | Description |
|---|---|
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;
}| Option | Description |
|---|---|
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;
}| Option | Description |
|---|---|
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. |
Spreadsheet View
Switch any collection list into a grid and edit many records inline — no opening one edit form at a time.
Custom workflows
A WorkflowConfig is a plain state machine — build your own when your process has different states, roles, or rules than the built-in publishing flow, with copy-paste recipes for approval chains, scheduling, and translation.