CMS
Headless CMS on the data layer — content types declared in code, derived draft/published tables, the ctx.cms read surface, and the saveDraft → publish write pipeline with save-time validation + derivation.
@voltro/cms is a headless CMS built on the framework's own data layer. You
declare a content type once, in code; the package compiles it to two real
database tables (<type>_drafts + <type>_published) that auto-migrate picks
up, gives you a typed read surface, and a write pipeline that enforces the
schema's validation/derivation rules on every save.
Two entry points keep the browser/server boundary clean:
@voltro/cms— the server entry: table derivation (@voltro/database), the write pipeline, signed preview tokens (node:crypto), the REST mount.@voltro/cms/web— browser-safe: the field DSL, the validation/derivation engine + its typed errors (effect-only), and the<ContentForm>renderer (react-only). An editor page pre-validates with the SAME rules the server applies; a mutation descriptor can declareerror: ContentValidationFailedwithout dragging the server graph into the bundle.
Declaring a content type
By convention each content type lives in a *.contentType.ts file. The field
DSL is a plain-data descriptor language — validation rules travel as data and
are applied at save time.
import { defineContentType, Schema, derivedFrom, slugify } from '@voltro/cms'
export const blogPost = defineContentType({
name: 'blogPost',
displayName: 'Blog Post',
pluralName: 'Blog Posts',
fields: {
title: Schema.String.pipe(Schema.maxLength(200)),
slug: Schema.String.pipe(Schema.pattern(/^[a-z0-9-]+$/), Schema.unique(), derivedFrom('title', slugify)),
body: Schema.RichText({ allowImages: true, allowEmbeds: false }),
tags: Schema.Array(Schema.String),
publishedAt: Schema.DateTime.optional(),
coverImage: Schema.Media().optional(),
author: Schema.Reference('author'),
visibility: Schema.Literal('public', 'unlisted', 'private'),
},
list: { columns: ['title', 'updatedAt'], defaultSort: { field: 'updatedAt', dir: 'desc' } },
})maxLength/patternare enforced by the save-time validator (andmaxLengthalso caps the shipped text widget).derivedFrom(source, transform)marks a field computed-on-save: the pipeline computes it from its source and overwrites a caller-provided value —slugifyis the canonical transform.unique()marks a string field unique per tenant — the derived tables get a real composite(tenantId, field)DB UNIQUE constraint (the database rejects a duplicate slug within a tenant, not the app). Pair it withderivedFrom('title', slugify)for a unique slug.Schema.RichText(options?)stores a JSON document (e.g. a TipTap doc);allowImages/allowEmbeds: falsereject image/embed nodes (or<img>/<iframe>/<embed>markers in string values) at save time. The shipped default widget is a plain textarea placeholder — swap in a real editor via theContentFormwidgetsoverride.Schema.Media()stores a storage object key as text (e.g. one issued by@voltro/plugin-storage); resolve it to a URL after a read withresolveMedia(below).listdescribes the consuming editor's list view; every referenced name is validated at definition time.- Reserved field names (
defineContentTypethrows):id,status,tenantId,createdAt,updatedAt,createdBy,updatedBy.
contentTypeToEntities(blogPost) returns the { draft, published } tables —
register them in your database/index.ts so auto-migrate creates them. Both
carry the lifecycle status column (draft / published / archived), the
tenant() mixin (tenant-scoped reads/writes through the request store), and
are .reactive() so a publish wakes live subscriptions.
Reading — ctx.cms
cmsContext(store, types) builds the read surface over a request-scoped
store; attach it to your AppContext and read it with useCms(ctx). Reads
target <type>_published and exclude archived rows.
import { cmsContext, useCms } from '@voltro/cms'
const posts = await useCms(ctx)
.contentType('blogPost')
.where('publishedAt', '<=', new Date())
.orderBy('publishedAt', 'desc')
.limit(20)
.all()createCmsClient({ store, types }) is the same surface without a request
handle (build-time / static-site generation). Signed preview tokens
(previewToken / verifyPreviewToken / cms.preview) let an app read the
draft row instead — see the package README for the token contract and the
API-key-gated REST mount (handleCmsRest).
Writing — the pipeline
The lifecycle ops take the caller's store first. Hand them ctx.store and
every write rides the runtime's auto-scope spine: tenant + audit columns are
stamped, reads are tenant-scoped, ids come from the table's id scheme.
import { saveDraft, publish, unpublish, archive } from '@voltro/cms'
// derive → validate → write into blogPost_drafts (status: 'draft')
const draft = await saveDraft(ctx.store, blogPost, input)
// copy the draft into blogPost_published (same id), atomic per row
const row = await publish(ctx.store, blogPost, draft.id as string)
await unpublish(ctx.store, blogPost, draft.id as string) // remove the published copy, keep the draft
await archive(ctx.store, blogPost, draft.id as string) // status 'archived' — reads exclude itsaveDraft applies the derivedFrom derivations first, then validates the
derived row — a rule on a derived field checks the value actually written.
Violations throw ContentValidationFailed carrying every per-field
violation ({ field, rule, message }), so an editor can annotate the whole
form in one pass. Re-saving with row.id updates the caller's own draft;
publish copies into the published table (insert on first publish, update
after) and marks the draft in-sync.
Tenant isolation is structural: a foreign id reads as absent through the
scoped store (ContentNotFound), a caller-pinned id that exists only in
another tenant is refused (ContentIdConflict), and a caller-provided
tenantId never reaches a write — the pipeline owns the lifecycle columns.
Handlers written in Effect.gen use the Effect-native siblings — the same
typed error instances on the error channel:
import { Effect } from 'effect'
import { saveDraftEffect, publishEffect } from '@voltro/cms'
const program = Effect.gen(function* () {
const draft = yield* saveDraftEffect(ctx.store, blogPost, input)
return yield* publishEffect(ctx.store, blogPost, draft.id as string)
}).pipe(
Effect.catchTag('ContentValidationFailed', (e) => Effect.succeed({ invalid: e.violations })),
)The engine, standalone
The validation/derivation engine is pure and exported on its own (also on
/web) for custom write paths and client-side form validation:
import { validateContent, contentViolations, applyDerivations } from '@voltro/cms/web'
const derived = applyDerivations(blogPost, formValue) // derivedFrom fields computed
const violations = contentViolations(blogPost, derived) // pure — never throws
validateContent(blogPost, derived) // throws ContentValidationFailed on any violationScheduled publishing
A draft can be marked to go live at a future time, then flipped live by a
periodic sweep. schedulePublish stamps the draft's publishAt (a draft-only
nullable column) without publishing now; publishDue publishes every draft
whose time has passed.
import { schedulePublish, publishDue } from '@voltro/cms'
// Mark a draft to publish later (does NOT publish now):
await schedulePublish(ctx.store, blogPost, draftId, new Date('2026-01-01T09:00:00Z'))
// Flip every due draft live (idempotent; returns the published ids):
const publishedIds = await publishDue(ctx.store, blogPost, new Date())schedulePublish keys off a tenant-scoped read (a foreign/missing id throws
ContentNotFound) and leaves status: 'draft' until the row goes live.
publishDue selects publishAt <= now AND status = 'draft', runs the ordinary
publish per row, and clears publishAt — idempotent, so a row is never
double-published. It sweeps whatever store it is handed: a request store covers
one tenant; a background/root store covers every tenant.
@voltro/cms does not ship the scheduler — you drive publishDue from a
*.cron.tsx schedule:
// apps/api/schedules/content-scheduler.cron.tsx
import { defineSchedule } from '@voltro/runtime'
import { publishDue } from '@voltro/cms'
import { blogPost } from '../blogPost.contentType'
export default defineSchedule({
name: 'contentScheduler',
cron: '* * * * *',
timezone: 'UTC',
// app.store has no request subject, so the sweep flips due drafts live
// across ALL tenants.
handler: async ({ app }) => {
await publishDue(app.store, blogPost, new Date())
},
})Media resolution
A Schema.Media() field stores a storage object key. Turning it into a
served URL needs the storage service (its getUrl / mintUrl are
Effect-returning and mintUrl needs the request Subject), so that context
lives in your app — not the core package. Rather than couple @voltro/cms to a
storage plugin, resolution is a typed hook: you supply a MediaResolver, and
resolveMedia swaps every media key on a row (including media nested in
Array / Struct fields) for the resolved URL.
import { resolveMedia, resolveMediaAll, type MediaResolver } from '@voltro/cms'
import { Effect } from 'effect'
const resolver: MediaResolver = (key) =>
Effect.runPromise(storage.mintUrl(key, ctx.subject)).catch(() => null)
const post = await useCms(ctx).contentType('blogPost').where('slug', '=', 'hello').one()
const withUrls = post ? await resolveMedia(blogPost, post, resolver) : nullresolveMedia returns a new row (the input is not mutated); a key the resolver
returns null for is left as its stored key. resolveMediaAll maps a whole
list; mediaFields(type) lists the top-level media field names.
Versioning content
@voltro/cms ships no parallel revision system — the derived tables are
ordinary database tables, so @voltro/plugin-versioning gives full row history
- time-travel with no new machinery. List the derived table names in the
plugin's
tablesoption, then read a timeline or restore a snapshot:
import { versioningPlugin, rowHistory, restoreAsOf } from '@voltro/plugin-versioning'
// Register in your app's plugin list:
versioningPlugin({ tables: ['blogPost_published', 'blogPost_drafts'] })
const timeline = await rowHistory(ctx.store, 'blogPost_published', postId, tenantId)
await restoreAsOf(ctx.store, 'blogPost_published', postId, tenantId, someEarlierDate)The editor form
<ContentForm> (from /web) renders one labelled widget per field, driven by
each field's editor hint — stateless and unstyled. Override any widget (e.g. a
TipTap-backed richText) via the widgets prop:
import { ContentForm } from '@voltro/cms/web'
<ContentForm contentType={blogPost} value={row} onChange={setRow}
widgets={{ richText: MyTipTapWidget }} />