Headless sites
How to run a site's public pages from a frontend you build (Astro, Next.js, Nuxt or a static generator) on the content API: delivery.mode headless in site.yaml and what check enforces, the resolve loop, preview from the editor, revalidation webhooks and the change feed, forms, analytics collection, and static export.
A site is rendered by default: the engine serves its pages from your templates. A headless site is served by a frontend someone else builds, which reads the content API. The admin, the content model, publishing, redirects, search and analytics stay the engine’s; the frontend owns the markup.
Rendered delivery is the better default. With it the engine writes the structured data, responsive images, accessibility defaults and analytics on every page without anyone remembering to. Choose headless when your organisation has already decided on a frontend framework, and use this page to give that frontend everything a rendered page would have had.
Configuration
# config/engine.yaml
server:
api:
content:
enabled: true # config/sites/<site>/site.yaml
delivery:
mode: headless
frontend:
origin: https://www.example.org
preview_url: https://www.example.org/api/preview?token={token}&path={path}
webhooks:
- url: https://www.example.org/api/revalidate
secret: ${FRONTEND_WEBHOOK_SECRET}
events: [content.published, content.unpublished, content.uri_changed, redirects.changed]
embed:
enabled: false | Key | Default | Meaning |
|---|---|---|
delivery.mode | rendered | rendered or headless. |
delivery.frontend.origin | (required when headless) | The frontend’s origin: a scheme and host, no path. Every canonical and absolute URL the API delivers is built on it, and a publishable key with no origins of its own allows it for CORS. |
delivery.frontend.preview_url | none | Where the editor’s “preview on the frontend” opens. Must contain {token}; {path}, {document} and {locale} are filled too, each URL-encoded. |
delivery.frontend.webhooks | none | Revalidation webhooks: url, secret and events. See Revalidation. |
engine-dmo check enforces, each as an error unless marked:
server.api.content.enabledis true (site main is headless and server.api.content.enabled is false, so nothing serves it).delivery.frontend.originis an origin with no path, query or credentials, andhttpsin production.preview_urlcontains{token}, is an absolutehttporhttpsURL, and ishttpsin production. One on another origin than the frontend’s is a warning, since the admin’s preview pane frames only the frontend’s origin.- Each webhook
urlis absolute,httpsin production and listed once; itssecretis a bare${VAR}of at least 16 characters; each event is one the engine emits. embed.enabledis not true: a headless site serves no widgets.delivery.frontendon a rendered site is an error (setdelivery.mode: headlessor remove it).- A public
postendpoint’srespondis unused on a headless site, since it is posted through the API’s forms operation: a warning. You may leaverespondout.
Media addresses in the representation are on the engine’s own server.base_url (or your storage’s public_base_url), since the frontend does not serve /media/.
The request loop
Every page a visitor asks for is one resolve call from the frontend’s server, and, for a document, one view report after the response has gone:
sequenceDiagram
participant V as Visitor
participant F as Frontend server
participant E as Engine content API
V->>F: GET /whats-on?page=2
F->>E: GET .../sites/main/resolve?path=...<br/>Bearer secret key
E-->>F: outcome, document or index, page metadata
alt document or index
F-->>V: 200, the page rendered from the answer
else redirect
F-->>V: status and location from the answer
else gone or not_found
F-->>V: its own 410 or 404 page
end
opt a document was served
F->>E: POST .../sites/main/events<br/>its analytics.view token
E-->>F: 202 accepted
endFor each page request, the frontend’s server:
Calls resolve with the visitor’s path and query string, with its secret key:
GET /api/content/v1/sites/main/resolve?path=%2Fwhats-on%3Fpage%3D2
Authorization: Bearer mrd_sec_... Switches on outcome: renders the document or index with the page metadata in the head; answers redirect with status and location; renders its 410 page for gone and its 404 page for not_found.
Reports the view, after the response, with the analytics.view token (see Analytics).
GET /sites/{site} gives the frontend the locales, routes, navigation and robots rules it needs once per build or cache period; strings its interface text; sitemap the entries for its sitemap.xml; redirects the compiled rules for an edge function. The content API page describes each.
engine-dmo api resolve /whats-on --site main --project . shows what the API answers for a path while you build the frontend.
Preview
When staff preview a document of a headless site from the editor, the admin issues a preview token for that document and locale (audited), and opens preview_url with the token in the admin’s preview pane or a tab.
Your frontend’s preview route must:
-
Keep the token on the server. Turn on the framework’s draft mode, keep the token in an HTTP-only cookie or the draft mode’s state, and redirect to
{path}(or a page of your own for a draft that has never been published and has no path). Never put the token in markup or a client bundle. -
Read with a secret key holding
preview, sending the token in theMeridian-Previewheader:GET /sites/{site}/resolve?path=...answers the token’s document with its working revisions when the path resolves to it, and what is published otherwise;GET /sites/{site}/previewanswers the token’s document directly, with its page metadata, for a draft with no path.
Both carry a
previewblock (document,locale,shared_revision,locale_revision,expires_at,substituted), areprivate, no-store, saynoindex, and record no 404 miss. -
Refetch on every request in draft mode and cache nothing read with a token: the token reads the working revisions each time, so a refresh after an autosave shows it.
-
Leave draft mode on
preview_expired(401: pastserver.api.content.preview_ttl, or the staff session it was issued in ended),preview_invalid(401),capability_missing(403: a publishable key, or a secret key withoutpreview), ornot_foundonce the document is trashed or its issuer may no longer read it.
Only that one document in that one locale is unpublished in the answer: everything else it refers to is published content. To be framed in the admin’s preview pane, your frontend’s own Content-Security-Policy must allow the admin’s origin in frame-ancestors; the admin’s policy already allows the frontend’s origin in frame-src when the site has a preview_url.
Revalidation webhooks
Each entry of delivery.frontend.webhooks receives a signed POST when the site’s public content changes, so the frontend can purge or rebuild exactly what moved.
| Event | When |
|---|---|
content.published | A locale of a document went live, including a scheduled publish |
content.unpublished | A live locale was taken down |
content.uri_changed | A live document’s address changed |
content.trashed | A live document was trashed |
content.restored | A document was restored from the trash |
redirects.changed | The engine wrote automatic redirect rules (with an address change or a trash) |
A webhook with no events receives all six. Changes are batched, up to 1,000 a delivery, one delivery per event kind: a bulk publish of 400 documents is one content.published delivery with 400 changes.
{
"id": "0191f3a0-6c4e-7f12-9b1d-2a7e5c3d1f00",
"type": "content.published",
"occurred_at": "2026-09-16T10:12:00Z",
"site": "main",
"cursor": "184467",
"changes": [
{
"cursor": "184467",
"document_id": "0191f0c2-7a51-7c3e-9d0a-5b2f6c1e4a88",
"type": "event",
"locale": "en-GB",
"kind": "published",
"uri_before": null,
"uri_after": "/whats-on/summer-fair",
"occurred_at": "2026-09-16T10:11:58Z",
"cache_tags": ["doc:0191f0c2-7a51-7c3e-9d0a-5b2f6c1e4a88", "type:event", "site:main"]
}
]
} cache_tags are the tags the published answers holding that document were sent with: purge them in your CDN. A redirects.changed change also carries redirects:<site>. Signing, retries, the delivery log and replay are the same as for every webhook: verify Meridian-Signature before acting, and deduplicate on Meridian-Delivery.
The change feed
Webhooks are an announcement; the change feed is the record. A frontend that must not miss a change (a static build, a search index, a sync) reads it with a secret key:
GET /api/content/v1/sites/main/changes?since=184400&limit=500 | Parameter | Meaning |
|---|---|
since | A cursor from the feed, a webhook or an export’s manifest: the changes after it. Without it, the answer holds no changes and next is the site’s head, where a client that has just rebuilt from the queries starts. |
limit | Changes a page, 500 by default and at most 1,000. |
The answer is {changes, next, resync_required}, in cursor order, each change in the webhook’s shape. kind is published, unpublished, uri_changed, trashed, restored or purged. Rows older than server.api.content.change_retention (90 days by default) are swept; a since older than that answers resync_required: true, and the client rebuilds from the queries. A publishable key, or a keyless read, is capability_missing.
Forms
A declared post endpoint with access: public (see Forms and submissions) is drawn by the frontend from a description and posted as JSON. Both need a key holding forms.
GET /api/content/v1/sites/main/forms/contact-listing?locale=en-GB&subject=<listing-id> {
"key": "contact-listing",
"locale": "en-GB",
"action": "/api/content/v1/sites/main/forms/contact-listing?subject=...",
"fields": [
{ "name": "name", "type": "text", "label": "Your name", "required": true },
{ "name": "email", "type": "text", "label": "Your email address", "required": true }
],
"challenge": {
"token": "v1.c8G...",
"min_fill_ms": 3000,
"honeypot": "website_url",
"proof_of_work": { "algorithm": "sha256", "difficulty": 8, "salt": "v1.c8G..." }
}
} - The labels and consent wording are the declaration’s, in the locale. File inputs are left out, since a JSON post carries no file.
subjectfills the endpoint’s path inputs (the listing an enquiry is about). - The
challenge.tokenreplaces the CSRF token. It is bound to the endpoint, dated formin_fill_ms, single use once the input is good, and accepted for twelve hours. Posthoneypot’s field empty. Whenproof_of_workis present, find aproofsuch thatsha256(token + proof)has at leastdifficultyleading zero bits. - Post the result:
POST /api/content/v1/sites/main/forms/contact-listing?subject=<listing-id>
Content-Type: application/json
{ "fields": { "name": "Sian", "email": "sian@example.org", "message": "...", "consent": true },
"challenge": "v1.c8G...", "proof": "4821" } - Success is
{"outcome": "sent", "signals": {...}}. A honeypot hit also looks like success, so a bot learns nothing. A field error is422 validation_failedwithfield_errorsas{path, code, message}; posting faster thanmin_fill_msis the same problem at_form. The endpoint’s own rate limit answers429 rate_limitedwithRetry-After. - From a browser, use a publishable key holding
forms, from an allowed origin; the rate limits key on the visitor’s address. - From the frontend’s server, use a secret key holding
formsand forward the visitor’s address inMeridian-Visitor-IP. The header is trusted on that combination only, hashed for the limiter and never stored.
The endpoint’s steps run as on a rendered site: store, mail, audit, analytics and subscribe behave the same. A member endpoint, a get endpoint and an unknown key all answer not_found; a member’s forms go through the member API.
Analytics
A rendered page records its view as it renders. A headless frontend must report views, or the site’s analytics (and, on engine-dmo, the member report) have none. resolve’s document outcome carries analytics: {view, collect}: a signed view token naming the site, the document, its type and the locale. Use one of two paths.
From the frontend’s server (recommended for a frontend that renders per request), with a secret key holding events, after the response has gone:
POST /api/content/v1/sites/main/events
Authorization: Bearer mrd_sec_...
Content-Type: application/json
{ "events": [
{ "name": "view",
"view": "v1.p9L...",
"path": "/whats-on/summer-fair",
"occurred_at": "2026-09-16T10:04:12Z",
"visitor": { "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 ...", "referrer": "https://www.google.com/", "accept_language": "en-GB,en;q=0.8" },
"campaign": { "utm_source": "newsletter", "utm_medium": "email", "utm_campaign": "autumn" } }
] } | Rule | |
|---|---|
| Names | view (or page_view, listing_view, event_view, which are the same), which needs the view token; or a client event the beacon accepts: listing_impression, website_click, phone_reveal, email_click, directions_click, booking_click, social_click, share, search, search_zero, search_click, filter_apply, event_click, download, widget_impression, offer_impression, promotion_impression, whose subject the token gives when sent. |
occurred_at | RFC 3339, up to a day old and five minutes ahead; now when absent. |
visitor | The visitor’s address is hashed under the day’s salt and discarded; the user agent feeds bot filtering. |
| Limits | At most 100 events and 512 KiB a request; 20 requests a second per key. |
| Answer | 202 {"accepted": n, "rejected": m}. An event that cannot be recorded (a bad or foreign token, a time outside the window, an unknown name) is counted in rejected and never fails the request. |
The beacon, proxied (required for a static frontend, allowed for any): proxy the engine’s analytics paths /_e, /_e.gif and /_e.js under your own domain, and report each view with its token from the page. See Analytics.
Collection is checked. engine-dmo analytics collection --site main [--days 7] --project . reports the events received by path (rendered, beacon, api) and the site’s collection health. When a headless site’s resolve has served documents for a day with no view events arriving, the engine logs a warning and notifies the site’s administrators.
Static export
A static frontend can build from files rather than requests: engine-dmo export content --site main --out ./content writes every answer the frontend needs, and --since <cursor> an incremental update. See Static export.
What stays on the engine
The admin, the content API, /media/ (or your storage’s public address), the analytics paths, the tracked link redirects under /r/, declared get endpoints at their own paths, mail provider webhooks, /health, and, on engine-dmo, the member API and the member sign-in screens. Your frontend proxies the visitor-facing ones (the analytics paths, /r/, the member API and sign-in) under its own domain, so they are first party.
The engine does not switch its own page routes off for a headless site. A request for one of the site’s page paths that reaches the engine on a host it serves for that site is answered from the project’s templates, as on a rendered site, or with the engine’s 404 page where nothing matches. Send only the paths listed above to the engine, and keep the site’s page traffic on the frontend.
Content API
The read-only JSON content API at /api/content/v1: turning it on, publishable and secret keys and their capabilities, CORS, every operation and parameter, the document representation and each field type's delivered shape, queries with filters, facets and cursors, caching headers and cache tags, every error code, and the rate limits.
Webhooks
How Meridian tells other systems that something happened: webhooks.yaml with every option and limit, the content events and the endpoint webhook step, the request body and headers, verifying the HMAC signature, retries and backoff, the delivery log, and replaying a delivery.