CMS · api + editor
A headless CMS on @voltro/cms — content types as code, drafts, revisions, review and releases, a public site with page tree, languages and consent, delivery and management APIs with keys, environments, usage limits and a license, audit log, experiments, audiences, embeds, forms, insights and imports from WordPress and Contentful. Two templates, api-cms and frontend-cms.
A complete headless CMS built from framework primitives: @voltro/cms for the content kernel, @voltro/plugin-auth and @voltro/plugin-rbac for editors and roles, @voltro/plugin-storage for images, @voltro/plugin-webhooks, @voltro/plugin-audit and @voltro/plugin-licensing. It comes as two templates that belong together: api-cms (the backend) and frontend-cms (the editor and the public site). It boots with nothing installed (store: 'memory') and runs on Postgres unchanged.
Scaffold
voltro create-project acme --api=api-cms --web=frontend-cmsWhat the editor does
| Area | Where |
|---|---|
| Content types as code — pages (tree, templates, sections), blog posts with their authors and categories, image assets, header, footer, site | content/*.contentType.ts |
| Drafts with revisions, conflict-free saves, history and restore; an opened draft saves itself after 20 seconds without a change, as a new revision, and stays open; changes not yet saved are kept in the browser and offered back after a reload; two editors typing in the same text field at once, both kept, each seeing where the other is; a live preview beside the form (click a section to edit it, move sections from it); Ctrl/Cmd+K goes to any area or document and runs the open draft's actions (save, publish, undo, redo, schedule, live preview); rich text edited as it looks (bold, italic, links); review before publish (blog posts always, pages when the workspace turns it on under Team); scheduled publishing and take-down, each time named with the site, its environment, the language and the time zone it is in; releases and rollback | editor, /releases, /calendar |
| Languages with fallback and translation status, XLIFF 2.1 export and import for translators; owners and review dates; bulk actions | /translations, /health |
| Images with alt text per language, focal point, rights and versions; bulk upload | /media |
| Search on the site and in the editor | /search, /find |
The blog: published posts under /blog/<slug> (and /de/blog/…), a newest-first index, each post with its author and category, and one list per category and author under /blog/category/<slug> and /blog/author/<slug> — all in the sitemap |
/blog |
Delivery API (/v1/content, /v1/site/…), change feed, OpenAPI, a generated TypeScript client, CDN cache headers |
/keys, /feed |
Management API for other systems, and AI agents over MCP writing drafts as their own key; in the editor a model proposes a title or description for search engines, a blog post's summary, or its saved text shorter or simpler, or an image's alt text from the image itself, in the media library too, where the images still without one get proposals ten at a time, and while a translation is written each short text field offers the source's text translated, following the workspace's glossary on /translations, and a saved translation names the words the glossary says not to use — used only when the editor takes it, every call recorded; each workspace uses the deployment's model (AI_PROVIDER, e.g. the AI gateway) or its own provider and key, with a monthly token budget (on Team) |
manage.*, agents |
| Webhooks, site build signals, operations view, environments (staging) and promotion | /webhooks, /operations, /environments |
Reader accounts: anyone can create one on /members and confirms the address by a mailed link (plugin-mail: MAIL_PROVIDER, e.g. lettermint; console logs it) before the members pages open; it lives in a readers' tenant of its own (signUpTenants); a members page can be limited to reader groups a publisher manages on /team; opens the pages for members and nothing of the editor, and takes no editor seat |
/members |
| Pages behind a password: a publisher sets it and sees it once (only a hash is kept); a reader who gives it gets an HttpOnly access cookie for the page and everything below it; strangers see only the form, and the page stays out of the navigation, search, sitemap and API; an image only such pages show stays private and reaches readers through signed links; invitation links open such a page without the password for some days and openings, confirmed by the reader, shown once and withdrawable | page field audience: password, POST /v1/site/unlock |
| Agent tools per branch of the page tree: a page turns the site's WebMCP tools on, off or locks them for everything below | page fields agentTools* |
| An inbox for each editor: review requests and decisions, and content reviews falling due — live, each item opening the draft | /inbox |
| Redirects: every address a page left behind and ones made by hand, removable; an address whose page was taken down answers 410 Gone | /redirects |
A starter site from a project blueprint — home, about, imprint, privacy notice, header, footer — or a blog (a category, an author, a first post awaiting review, and a team: new editors are authors, the managing editor is invited as publisher and gets an e-mail whose link opens the sign-up with that address; on /team a publisher invites anyone else the same way, and withdraws an invitation), in both languages; only what the workspace lacks is created, and a second run creates nothing |
/starter |
| Usage and limits per workspace; an offline license that sets them; editor seats | /usage |
| Audit log of every write, with CSV export | /audit |
Page experiments with a hypothesis, a goal and an optional guardrail (B at most so many points below A — breaches are shown), and audience variants, both decided on the server and only with consent where they identify a reader; consent receipts kept on the server with a /privacy view |
/experiments |
| Embeds other websites may frame (by origin; a public one never shows a page for members or behind a password, a private one shows it only to readers the other site signs in, by a five-minute grant its server gets with a delivery key), contact forms (in the preview — which pins site, environment, language, revision and build, and switches the perspective (draft, published, or as a release would put it live, opened from the release), width, audience and what the reader allowed in its address — they run in test mode: what an editor sends there is kept marked as a test and reaches no webhook), reach measured with consent | /embeds, /submissions, /insights |
| Imports from WordPress (WXR) and Contentful (JSON) as drafts, with images and old addresses; a Contentful mapping (type and fields) is kept per space and versioned, and the next export starts from it | /import |
Extensions: each shows everything it may do before it is installed and needs consent to all of it; a newer version that asks for more waits for consent while the installed one keeps running; a rollback returns to the version before; revoking takes every right away and keeps its data; a secret it asks for is set there and never shown back; uninstalling hands back what it stored. The sample "Reading time" shows in the editor's sidebar "Content stats" on the inbox (the dashboard) "Snippet length" beside the fields for search engines (installed with no capabilities at all) "SEO check" beside a release, "Rights check" as an action on an image in /media and "Word list" as a tab of the open entry (from 1.1.0 with settings on its card) (entries whose published revision lacks a title or description for search engines), each run in a sandbox that refuses what it was not granted |
/extensions |
Each of these is a set of ordinary procedures, pages and tests in the two apps — read them as examples of the primitive they use. The api's README.md lists every procedure with what it guards and answers.
What it deliberately does not build
These are decisions, not gaps — each has the form it takes instead:
- No GraphQL runtime. Content is read over the typed RPC procedures and the REST delivery API (
/v1/…), which stay on the framework's reactivity and access checks. - No visual schema builder that changes production. Content types are TypeScript (
content/*.contentType.ts); a change is a reviewed code change with its migration. A designer, if one comes, may only produce such a patch. - No global WYSIWYG HTML blob. Rich text is a structured document; pages are typed sections and references.
- No automatic publishing of AI output. A model only proposes; an editor takes a proposal into the form, and publishing stays a separate, permission-checked step.
- No free HTML, CSS or JavaScript blocks. Layout and behaviour come from registered components and tokens; stored markup is shown as text.
- No own CDP, analytics warehouse, payments, newsletter delivery, PIM, commerce or broadcast DAM. The CMS integrates them — mail, for instance, is delivered by a provider such as Lettermint.
- No shared database for cloud billing and customer content. The control plane and content live apart.
- A content environment is not an app deployment. Content environments (production, staging) can be bound to deployments but stay separate resources.
- No configurable regex redirects in the first release. A moved page redirects from its exact former path to the document, so there are no chains or cycles to analyse.
Security defaults worth knowing
- The first account of a workspace is a publisher; later sign-ups get
CMS_NEW_EDITOR_ROLE(defaultauthor). A sign-up past the seat limit is refused before the account exists. - A component that loads a third party (video) is withheld until the reader allows it; experiments and reach measurement need their own consent purposes.
- Delivery keys are stored as hashes, scoped (read, preview, manage) and bound to an environment; the audit trail keeps identifiers, not content or tokens.
- No page of the editor or the site can be framed, except the preview pages, which only the app's own origin may frame (the live preview beside the form;
middleware.ts). /v1/embed/:idanswers with a CSP whoseframe-ancestorslists only the allowed origins; image imports refuse internal addresses.- The embed snippet sandboxes its frame (scripts, same origin, top navigation on a click — nothing else), sends no referrer and loads
/v1/embed.jswithintegrity(the script is the same bytes everywhere, served with CORS). It carries the embed's contract fingerprint; when the embed's current one differs, the host script says so in the console.