⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS
Guides

Build your own editor

A recommended architecture for a custom editor UI on the createCMS API.

createCMS is headless: it ships the versioned content API (branches, merge requests, approvals, publishing, media, notifications) but no editor UI of its own. The headless editor primitives live in @createcms/react; the surrounding UI you bring yourself.

This guide maps each editor surface (dashboard, branch picker, merge-request flow) to the API method that backs it. For composing Editor.Root, canvas, and registry chrome, see the Visual editor guide.

The examples use the type-safe client (cmsClient), since an editor runs in the browser. Every method also exists on cms.api.<collection> server-side with identical types, so the same mapping holds if you drive it from a route handler or a server action. Examples assume a pages collection.

This is the architecture, not a component library. For the model behind the loop, read Draft, review, and publish and the concept pages on Branches and Merges first.

The editorial loop

An editor walks one entry from a draft to live content. Each step below is a UI surface and the method that backs it.

1. List entries (the dashboard)

The landing screen is a paginated table of an entry's roots. listRoots returns { roots, total, hasMore }; each root carries slug, typed properties, branchCount, and openMergeRequestCount so you can badge rows that have work in flight:

const { roots, hasMore } = await cmsClient.pages.listRoots({
  query: { limit: 20, sortBy: 'createdAt', sortDirection: 'desc' },
});

Create a new entry from this screen with createRoot (body: { slug, properties }), which returns the new rootId and its branchId on main.

2. Open an entry on a draft branch

Never edit main directly. When the user opens an entry, branch off its default branch with createBranch, then load that branch's tree with getBlockTree. Pass raw: true so stored values (link and reference targets, {{variables}}, image asset ids) come back unresolved and stay editable:

const { branch } = await cmsClient.pages.createBranch({
  body: { rootId, name: 'draft', sourceBranchId: mainBranchId },
});

const { tree } = await cmsClient.pages.getBlockTree({
  query: { rootId, branchId: branch.id, raw: true },
});

tree is a discriminated union over type with a type: 'root' node at the top. Render it recursively: that recursion is your block editor.

3. Edit blocks

Every block edit is a commit on the branch. Four methods cover the block toolbar:

const { blockId } = await cmsClient.pages.createBlock({
  body: {
    rootId,
    branchId: branch.id,
    parentBlockId: rootId,
    type: 'hero',
    properties: { headline: 'Welcome' },
  },
});

await cmsClient.pages.updateBlock({
  body: { rootId, branchId: branch.id, blockId, type: 'hero', properties: { headline: 'Welcome back' } },
});

updateBlock is a patch: supplied fields overwrite, omitted fields stay, null deletes. Reorder and reparent with moveBlock (body: { blockId, newParentBlockId, newIndex }), and remove with deleteBlock (body: { blockId }). Both take the same rootId and branchId. After each mutation, re-read getBlockTree (or apply the returned commit optimistically) to refresh the canvas.

If you batch edits client-side and save the whole tree at once instead of one mutation per change, use updateBlocks (body: { rootId, branchId, tree }). It diffs the tree against the branch head and creates, updates, and deletes blocks in a single commit; pass expectedHeadCommitId (the commit you loaded the tree at) so a concurrent write fails with HEAD_MISMATCH rather than clobbering it. See updateBlocks for the full contract.

When you mint block ids on the client (for optimistic inserts, or to build a tree before saving with updateBlocks), use the package's id helper so ids share the blk_ namespace: import { newId } from '@createcms/core/nanoid' then newId('block'). updateBlocks accepts any string id, but staying on the convention keeps ids consistent with server-created blocks.

To seed a new block's properties, resolve its template defaults on the server rather than reimplementing them in the editor. cmsClient.templates.getTemplateDefaults({ collection: 'pages', blockType }) returns a { propertyKey: value } map with every configured template resolved (its {{variables}} substituted through the active scope). Use it as the starting properties for a createBlock call or a new node in a batch updateBlocks save. See Templates for the full method.

The placement rules an editor enforces (which block types may nest under which) live in your collection's structure map. To inspect them at runtime instead of fighting the compile-time BlockStructureEntry types, use the exported helpers: buildPlacementIndex(structure) builds a lookup once, and isPlacementAllowed / allowedChildTypes answer "can this child go under this parent?" and "what may I offer in the add-block menu here?". Drive your palette and drag-drop validation from those so the UI matches what the server will accept. See Block placement.

4. Preview the draft

To render a live-accurate preview, read the tree with references resolved. getBlockTree with includeReferencePreviews: true returns a references sidecar (the published render tree of every embedded reference) alongside the raw editable tree, so you preview and edit from one call:

const { tree, references } = await cmsClient.pages.getBlockTree({
  query: { rootId, branchId: branch.id, raw: true, includeReferencePreviews: true },
});

To preview edits that are not saved yet, post the editor's working tree to resolveTree — same variables, links and reference handling, applied to the tree you send, nothing written:

const { tree: resolved, references } = await cmsClient.pages.resolveTree({
  rootId,
  branchId: branch.id,
  tree: editedTree,
  includeReferencePreviews: true,
});

For the exact published view an anonymous visitor would get, call getPublishedContent instead (it returns resolved variants).

5. Propose the change (merge request)

When the draft is ready, open a merge request back into main with createMergeRequest. The response reports conflicts up front:

const mr = await cmsClient.pages.createMergeRequest({
  body: { sourceBranchId: branch.id, targetBranchId: mainBranchId, title: 'Update hero' },
});

Show reviewers what the draft changes with getDiff: its flat diff list classifies every changed block (added, deleted, modified, moved, childrenReordered) with the full sourceVersion / targetVersion / baseVersion payloads a side-by-side view needs, and its annotated tree renders the diff with your own block components (see Review changes visually). If mr.hasConflicts is true, drive a conflict UI from checkConflicts and settle each conflict through applyConflictResolutions (as source, target, or a manual version) before merging.

6. Request and grant approval

Every merge needs an approval. requestApproval notifies the reviewers you name and returns the pending approvals; a reviewer clears theirs with submitApproval (or turns it back with submitRejection):

const { approvals } = await cmsClient.pages.requestApproval({
  body: { mergeRequestId: mr.mergeRequest.id, requestedReviewers: ['editor-2'] },
});

await cmsClient.pages.submitApproval({ body: { approvalId: approvals[0].id } });

The reviewer identity comes from the request's auth context, so build submitApproval behind whatever session your app already has.

7. Merge

Once approved, integrate the branch with executeMerge:

await cmsClient.pages.executeMerge({ body: { mergeRequestId: mr.mergeRequest.id } });

8. Publish

Merging updates main but does not make it live. Publish the target branch with publishBranch to promote it. After this, getPublishedContent (and your public site) serve the new version:

await cmsClient.pages.publishBranch({ body: { rootId, branchId: mainBranchId } });

9. Surface the inbox

Approvals, merge-request activity, mentions, and publishing all raise notifications for the affected users. Poll notifications.list to render a per-user inbox and an unread badge, and write back read state:

const { notifications, unreadCount } = await cmsClient.notifications.list({
  query: { unreadOnly: true },
});

await cmsClient.notifications.markNotificationsRead({ body: { notificationId } });

Components you'll build

The loop above decomposes into four reusable surfaces. Each is a component (or set of components) backed by a known slice of the API.

  • Block editor. Renders the tree from getBlockTree (with raw: true) and mutates it with createBlock, updateBlock, moveBlock, deleteBlock, and duplicateBlock. Every edit is a commit, so undo is a branch/revert concern, not editor state.
  • Media library. Uploads from the browser with the media.useUploadAssets() hook (or media.createSignedUpload server-side), browses with listAssets and listFolders, and organizes with createFolder, moveAssets, and updateAssetsStatus. Picking an asset stores its id in an image property; serve it through the gate at /media/asset/{id} (see Upload and serve media).
  • Branch, merge request, and approval views. Lists work in flight with listBranches and listMergeRequests, shows a diff with getDiff and checkConflicts (render it visually per Review changes visually), and drives the review with createMergeRequest, applyConflictResolutions, requestApproval, submitApproval, submitRejection, and executeMerge. Thread discussion on top with createCommentThread and listCommentThreads (see Review with comments).
  • Notification inbox. Reads with notifications.list and clears with markNotificationsRead, markNotificationsUnread, and archiveNotification.

Every method named here is documented with exact inputs and return shapes in the Server API and mirrored on the Client API.

On this page