DynoStaticAdmin sign in
Documentation

Headless CMS

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

  1. Open a website → Content.
  2. Create a collection (for example posts) or singleton (settings) and define fields as JSON.
  3. Add entries, save drafts, publish.
  4. Upload media and reference media ULIDs from image/file fields.
  5. Create a Content API token (shown once).
  6. Under Integrations, create a webhook subscribed to content.published (and optionally content.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. Pass limit=50 for smaller pages.
  • next_cursor — opaque token for the next page; null on the last page. Pass it back as cursor in 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=1 pages return status per entry and include drafts.
  • An invalid cursor or out-of-range limit returns 422 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.js on 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.