Headless CMS (Content API)
DynoStatic includes a Keystatic-class headless CMS for Astro, Hugo, and other static-site generators. Editorial work happens in admin under Content. Builds consume an authenticated JSON Content API. Embed widgets remain separate runtime islands for comments, forms, and interactive modules.
Concepts
| Keystatic idea | DynoStatic |
|---|---|
| Collections | Website-scoped schemas with slug entries |
| Singletons | One document per schema (settings, homepage) |
| TypeScript schema | Stored field definitions (JSON import/export) |
| Git / local storage | MySQL drafts + immutable published snapshots |
| Reader API | Bearer Content API |
| Image fields | Media library + media.php URLs |
| Markdoc / document | Markdown source; published HTML + plain text |
| Publish | Draft revision → publication snapshot |
Admin workflow
- Open a website → Content.
- Create a collection (for example
posts) or singleton (settings) and define fields as JSON. - Add entries, save drafts, publish.
- Upload media and reference media ULIDs from image/file fields.
- Create a Content API token (shown once).
- Under Integrations, create a webhook subscribed to
content.published(and optionallycontent.unpublished,media.updated,content.schema_changed,content.translations_changed) pointing at your host’s build hook.
Managers and website owners can write CMS data. Website-scoped client editors can edit content; managers choose draft-and-review (the default) or direct content publishing for each website. Viewers can open Content read-only. See Content and widget editing for permissions and publication behavior.
Built-in static website publishing
Website → Site publishing turns published Pages and Blog posts into a portable static ZIP using trusted PHP templates. It is separate from the private Content API workflow: no external static-site generator or Node process is required, and creating a release or ZIP does not deploy it or change DNS.
The presentation form selects the routed collections and fields, homepage, Blog archive, menus, taxonomy archives, appearance, widget regions, redirects, and optional static site search. Search writes one canonical JSON corpus, a fixed local DOM-only script, and a search-only stylesheet into the ZIP. Only final published Pages and Blog posts enter that corpus. Drafts, archive pages, raw markup, widget settings, provider facts, and credentials do not. The search page is noindex,follow; normal result pages keep their configured indexing policy. Search works after extraction at a domain root or subfolder, sends no query or content to DynoStatic or another search provider, and has a truthful navigation fallback when JavaScript is unavailable. Serve the extracted site over HTTPS, or from localhost while developing, because browsers expose the Web Crypto verification used for the index only in a secure context.
The panel can render an authenticated draft preview before release. An immutable release snapshots exact publications, theme, presentation, appearance, media and collection dependencies, routes, search corpus, and generated-asset identities. Later edits do not alter an old release. The verified ZIP supports server media URLs or copied originals and contains a checksummed credential-free manifest. Deployment remains an explicit later operator action.
Content API
Physical gateway: api/?action=… (no rewrite required). All routes require:
Authorization: Bearer dynostatic_cms_…
Do not send browser Origin for these routes. Tokens bind to a website only. Treat responses as private (do not put the token in public frontend code or shared CDN caches).
| Action | Query | Result |
|---|---|---|
cms.manifest |
— | Collections, singletons, field summaries, content_version |
cms.translations |
— | Active translation groups with published members only |
cms.form |
id=FORM_WIDGET_ULID |
Complete published website form and all its locale text; private content:read, no drafts; HTTP 409 while translations are incomplete |
cms.routes |
limit=500&cursor=… |
Published route index (cursor-paginated, max 1,000) |
cms.collection |
collection=posts&locale=fr-CA |
Published entry index (cursor-paginated; locale optional) |
cms.entry |
collection=posts&slug=hello&locale=fr-CA |
Full published snapshot (locale optional) |
cms.singleton |
singleton=settings&locale=fr-CA |
Published singleton snapshot (locale optional) |
cms.media |
id=ULID |
Binary media (also available publicly at /media.php?id=ULID) |
Successful JSON responses include ETag and X-DynoStatic-Content-Version. Send If-None-Match with a prior ETag to receive 304 Not Modified. Document fields publish as:
{
"source": "# Hello",
"html": "<h1>Hello</h1>",
"plain": "Hello"
}
Image/file refs in snapshots expand to { "id", "url", "mime", "alt", "width", "height" }.
Every schema in cms.manifest includes its canonical locale, route, and document projection policy. Structured JSON keeps metadata inline. Markdown and MDX use a declared top-level document field as the body and YAML front matter for the remaining fields. HTML and plain-text bodies use that same declared field and a JSON metadata sidecar. The Content API describes this deterministic projection, and the standalone PHP-only workspace client now serializes and reverses it for bulk local/AI authoring.
If locale is omitted, reads use the schema's default locale. An explicit locale must be declared in that schema or the API returns 422 invalid_locale.
Collection pagination
cms.collection is cursor-paginated so large SSG collections stream in stable pages. Every page carries the same entries shape plus a meta block:
{
"collection": "posts",
"content_version": 12,
"preview": false,
"entries": [
{ "id": "…", "slug": "…", "title": "…", "content_version": 12, "updated_at": "…", "published_at": "…", "data": { … } }
],
"meta": {
"total": 250,
"limit": 100,
"next_cursor": "…"
}
}
limit— page size, default 100, max 500. Passlimit=50for smaller pages.next_cursor— opaque token for the next page;nullon the last page. Pass it back ascursorin the next request. Cursors are stable for the current ordering (updated_at DESC, id DESC); re-publishing an entry can shift page boundaries, so treat pages as best-effort snapshots.preview=1pages returnstatusper entry and include drafts.- An invalid
cursoror out-of-rangelimitreturns422 invalid_cursor/422 invalid_limit.
curl -sS -H "Authorization: Bearer $DYNOSTATIC_TOKEN" \
"$DYNOSTATIC_URL/api/?action=cms.collection&collection=posts&limit=50"
# follow pages until meta.next_cursor is null
curl -sS -H "Authorization: Bearer $DYNOSTATIC_TOKEN" \
"$DYNOSTATIC_URL/api/?action=cms.collection&collection=posts&limit=50&cursor=…"
Route pagination
cms.routes returns the currently published, enabled CMS routes without making cms.manifest grow with the website. Its opaque cursor is ordered by schema and stable entry identity. The default page size is 500 and the maximum is 1,000. Schema route-policy changes and publication share a website-level transaction lock, so two schemas or locales cannot commit the same compiled path.
curl -sS -H "Authorization: Bearer $DYNOSTATIC_TOKEN" \
"$DYNOSTATIC_URL/api/?action=cms.routes&limit=500"
Examples
export DYNOSTATIC_URL='https://cms.example.com'
export DYNOSTATIC_TOKEN='dynostatic_cms_…'
curl -sS -H "Authorization: Bearer $DYNOSTATIC_TOKEN" \
"$DYNOSTATIC_URL/api/?action=cms.manifest"
curl -sS -H "Authorization: Bearer $DYNOSTATIC_TOKEN" \
"$DYNOSTATIC_URL/api/?action=cms.routes"
curl -sS -H "Authorization: Bearer $DYNOSTATIC_TOKEN" \
"$DYNOSTATIC_URL/api/?action=cms.collection&collection=posts"
curl -sS -H "Authorization: Bearer $DYNOSTATIC_TOKEN" \
"$DYNOSTATIC_URL/api/?action=cms.entry&collection=posts&slug=hello-world"
curl -sS -H "Authorization: Bearer $DYNOSTATIC_TOKEN" \
"$DYNOSTATIC_URL/api/?action=cms.singleton&singleton=settings"
Webhooks / rebuilds
Signed deliveries use X-DynoStatic-Signature like other DynoStatic webhooks. CMS event data includes schema_key, schema_kind, entry_id, slug, media_id, and content_version when relevant.
CLI cron (bin/dynostatic cron) is the recommended path for timely content.published delivery. The probabilistic web runner is best-effort only.
Point the endpoint at:
- Netlify build hook
- Vercel deploy hook
- Cloudflare Pages deploy hook
API tokens authenticate with O(1) HMAC lookup and are rate-limited. Scope content:read is required for Content API access. Scope content:preview allows preview=1 draft reads. Pass signed_media=1 (or X-DynoStatic-Signed-Media: 1) to receive HMAC-expiring media URLs.
Machine contract: cms-openapi.yaml. Host matrix: compatibility.md.
Field types
text, slug, integer, number, checkbox, select, multiselect, date, datetime, url, image, file, relationship, object, array, conditional, document.
Relationships must target published collection entries when publishing. Conditionals use show_when: { field, equals }.
Text fields can opt into "editor": "inline_html" or "editor": "rich_html"
in the schema editor. Both provide Visual and Code (HTML) modes, bold,
italic, underline, strikethrough, inline code, subscript/superscript, line breaks,
clear formatting and undo/redo. Rich fields also offer paragraph/headings,
quotes, code blocks, lists, links, rules and tables. Expand editor provides
more writing space. Nested and repeated fields use the same controls.
Insert Markdown in rich fields uses the existing authenticated CMS preview endpoint and the same CommonMark renderer and Symfony sanitizer as comments. The inserted result becomes editable HTML. Code mode edits HTML, not Markdown; there is no lossy HTML-to-Markdown conversion. No editor package, CDN or build step is required by the PHP installation. Browser-native visual editing extends the existing CMS field and editor-shell lifecycle.
Opening and switching modes without editing preserves the original string,
including named entities. Invalid code remains visible for correction; it is
never silently discarded when selecting Visual. Paste removes scripts, images,
styles and unsupported markup. Rich links allow HTTP(S), mailto, tel, relative
paths and anchors. Image fields and site templates continue to own photographs
and page layout. The portable vocabulary is in assets/cms-html-policy.js.
These are presentation settings: text storage, server validation, length limits and published content remain unchanged. They can change without unpublishing entries or advancing the schema compatibility generation. Unsupported existing markup stays in escaped Code mode; without JavaScript, the textarea remains available. Frontends must validate or sanitize text before rendering HTML. A consuming frontend must support a rich field's vocabulary and use a block container before enabling that profile; an inline heading cannot contain lists or tables. Visual editor checks are not a server security boundary.
Boundary vs widgets
- CMS owns site content the SSG renders at build time (blog posts, marketing pages, global settings).
- Widgets are embeddable runtime islands (comments, forms, polls, menus) loaded by
embed.json the live site.
Do not model CMS documents as widgets. See architecture.md.
Astro and Hugo
See integration.md and the copy-paste build clients for
examples/astro-cms/ and
examples/hugo-cms/. These private build-time clients
are separate from the public widget components under examples/*-widgets/.
Published translations for external frontends
GET /api/?action=cms.translations uses the same site-scoped content:read bearer token as collections. It returns { "groups": [{ "id": "…", "revision": 1, "schema": "pages", "members": [{ "entry_id": "…", "locale": "en" }] }], "preview": false }. The inventory is bounded by the CMS's 200 active groups and 32 members per group.
Join members to published collection/singleton identities, then use each entry's actual public path. Do not infer translation relationships from equal slugs. Draft-only, unpublished and deleted entries are excluded, as are memberships whose locale no longer matches the published snapshot. Groups with no public members are omitted. Content and draft fields are never included. ETags cover the full projection; the entry content-version header alone does not identify a group edit.
Group creation, changed membership and deletion enqueue content.translations_changed in the same database transaction. No-op retries enqueue nothing. Subscribe deployment hooks to this event as well as publication events. Read groups again during a build's consistency check, since group links can change independently of content. Independently edited groups connect authored translations. Opt-in source-managed translations add source-only editing, freshness tracking and the shared manual/AI refresh queue without changing this public API.