Content Workflow
Dune includes a built-in editorial workflow with four content states. You can also define fully custom stages and transitions with per-role permissions — no code required, just YAML config.
States
| Status | Description |
|---|---|
draft |
Work in progress — not visible to visitors (unless published: true is also set) |
in_review |
Submitted for editorial review — not yet public |
published |
Approved and live — visible to visitors |
archived |
Removed from circulation — not visible, kept for historical reference |
The status frontmatter field sets a page's workflow state:
---
title: "My New Article"
status: draft
---
Allowed transitions
Not all state changes are valid. The engine enforces the following transition graph:
draft ──────────→ in_review ──→ published ──→ archived
↑ │ │
└───────────────────┘←─────────────────────────┘
↑ │
└──────────────────────────────────────────────┘
Specifically:
| From | To | Notes |
|---|---|---|
draft |
in_review |
Submit for review |
draft |
published |
Publish directly (bypass review) |
in_review |
published |
Approve and publish |
in_review |
draft |
Return to author for changes |
published |
archived |
Archive published content |
published |
draft |
Unpublish and return to draft |
archived |
draft |
Restore from archive |
Any other transition (e.g. archived → published) is invalid and will be rejected.
Custom workflows
Override the built-in 4-stage workflow by adding a workflow: block to site.yaml. Define your own stages and transitions with labels, colors, and per-role permissions.
Defining stages
# site.yaml
workflow:
stages:
- id: "draft"
label: "Draft"
color: "amber"
- id: "in_review"
label: "In Review"
color: "blue"
- id: "needs_changes"
label: "Needs Changes"
color: "orange"
- id: "approved"
label: "Approved"
color: "teal"
- id: "published"
label: "Published"
color: "green"
publish: true # sets published: true on transition in
- id: "archived"
label: "Archived"
color: "gray"
terminal: true
transitions:
- from: "draft"
to: "in_review"
label: "Submit for Review"
roles: ["admin", "editor", "author"]
- from: "in_review"
to: "approved"
label: "Approve"
roles: ["admin"]
- from: "in_review"
to: "needs_changes"
label: "Request Changes"
roles: ["admin"]
- from: "needs_changes"
to: "in_review"
label: "Resubmit"
roles: ["admin", "editor", "author"]
- from: "approved"
to: "published"
label: "Publish"
roles: ["admin"]
- from: "published"
to: "archived"
label: "Archive"
roles: ["admin"]
- from: "published"
to: "draft"
label: "Unpublish"
roles: ["admin"]
- from: "archived"
to: "draft"
label: "Restore"
roles: ["admin"]
Stage fields
| Field | Type | Description |
|---|---|---|
id |
string |
Unique identifier — stored as the status value in frontmatter |
label |
string |
Display name in admin UI |
color |
string |
Badge color: amber, blue, green, gray, orange, teal, red, purple |
publish |
boolean |
If true, transitioning to this stage sets published: true on the page |
terminal |
boolean |
Informational marker — no effect on transitions |
Transition fields
| Field | Type | Description |
|---|---|---|
from |
string |
Source stage ID |
to |
string |
Target stage ID |
label |
string |
Button label in admin UI (e.g. "Approve", "Request Changes") |
roles |
string[] |
Roles allowed to perform this transition. If omitted, any role can perform it. Valid: admin, editor, author |
Role-based permissions
When roles is set on a transition, only users with one of those roles see the transition button and can perform the action. The server enforces this — a role mismatch returns 403 Forbidden.
Omitting roles means any authenticated admin user can perform the transition.
REST API
GET /admin/api/workflow/stages
Returns all configured stages. No authentication required (used by the admin UI to render status badges on page lists).
GET /admin/api/workflow/status/{path}
Returns the current status, all allowed transitions (filtered to the requesting user's role), and the full stages list.
{
"sourcePath": "content/blog/my-post/default.md",
"status": "in_review",
"allowedTransitions": ["approved", "needs_changes"],
"transitions": [
{ "to": "approved", "label": "Approve" },
{ "to": "needs_changes", "label": "Request Changes" }
],
"stages": [...]
}
POST /admin/api/workflow/transition
{ "sourcePath": "content/blog/my-post/default.md", "status": "approved" }
Returns 403 if the requesting user's role is not permitted to make the transition.
Status vs. published
status and published are independent fields:
published: false— page has no URL and does not exist for visitors, regardless of statuspublished: truewithstatus: draft— technically accessible via URL, but the workflow treats it as a draft
In practice, use status for editorial tracking and use published: false for pages that should be completely hidden.
Scheduling
The admin panel supports scheduled publish and unpublish actions. These are stored as scheduled actions and applied by the Dune scheduler at the configured time.
Frontmatter equivalents:
publish_date: "2025-12-01T09:00:00Z" # Auto-publish at this time
unpublish_date: "2026-01-01T00:00:00Z" # Auto-unpublish at this time
Draft preview
Before publishing, editors can preview how a draft will look on the live site — with the active theme, layout, and styles applied — without making the page public.
Creating a preview
From the page editor, save a draft and click Preview Draft. This generates a secure, shareable preview URL:
https://example.com/__preview?path=content%2Fblog%2Fmy-post%2Fdefault.md&token=abc123…
The URL can be shared with reviewers who don't have admin access — no login required.
How it works
- Draft content (frontmatter + body) is saved to the staging area in
admin.runtimeDir(default.dune/admin/staging/). - A random opaque token is generated and tied to the draft. The token is preserved across subsequent saves so shared links remain valid.
GET /__previewverifies the token and renders the draft through the active theme, with an orange "Draft preview" banner injected so viewers know the page is not live.- Staging files are ephemeral — they live in
runtimeDirand are not committed to version control.
Publishing a staged draft
Clicking Publish in the panel writes the staged draft to the content files, records a revision, and optionally creates a git commit (see Git auto-commit below). The staging file is removed after a successful publish.
Git auto-commit
When admin.git_commit: true is set in your config, every page save and staged publish triggers a git add + git commit automatically:
# dune.config.ts
export default {
admin: {
git_commit: true,
},
};
The commit message includes the page path and the editor's username:
Admin: update content/blog/my-post/default.md (by jane)
Git must be available in the server's PATH. Commit failures are logged as warnings and do not block the save.
Revision history
Every time a page is saved through the admin panel, a revision is recorded. Revisions store:
- Complete page content at that point in time
- Frontmatter snapshot
- Author who made the change
- Timestamp
- Optional change message
Storage
Revisions are written to the runtime data directory (configured as admin.runtimeDir, default .dune/admin). Each page gets its own folder:
.dune/admin/history/{url-encoded-source-path}/{revision-number}.json
This directory is ephemeral — it should be in .gitignore and is not committed to version control.
⚠️ Deploy warning: Because revisions live in
runtimeDir, they are not preserved across deployments to a fresh server. Every cold deploy (new container, new VM, fresh clone) starts with an empty revision history. If you need persistent revision history across deploys, back up theruntimeDirbefore deployment and restore it after, or mount it on persistent storage.
Capacity
Up to 50 revisions per page are kept by default. Older revisions are automatically pruned when a new save would exceed the limit. Configure the limit via admin.maxRevisions:
admin:
maxRevisions: 100 # keep more history (uses more disk)
Accessing revisions
Revisions are accessible from the admin panel's page editor: open any page, then click History to browse the revision timeline, compare versions, and restore a previous version.
The admin panel also exposes revisions via its internal API (used by the panel UI):
| Method | Endpoint | Description |
|---|---|---|
GET |
/admin/api/pages/{route}/history |
List all revisions for a page |
GET |
/admin/api/pages/{route}/history/{n} |
Get a specific revision |
POST |
/admin/api/pages/{route}/history/{n}/restore |
Restore revision n as the current content |
These endpoints require admin authentication (editor role or above).