Meridian handbook
Integrating

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
KeyDefaultMeaning
delivery.moderenderedrendered 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_urlnoneWhere the editor’s “preview on the frontend” opens. Must contain {token}; {path}, {document} and {locale} are filled too, each URL-encoded.
delivery.frontend.webhooksnoneRevalidation webhooks: url, secret and events. See Revalidation.

engine-dmo check enforces, each as an error unless marked:

  • server.api.content.enabled is true (site main is headless and server.api.content.enabled is false, so nothing serves it).
  • delivery.frontend.origin is an origin with no path, query or credentials, and https in production.
  • preview_url contains {token}, is an absolute http or https URL, and is https in 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 url is absolute, https in production and listed once; its secret is a bare ${VAR} of at least 16 characters; each event is one the engine emits.
  • embed.enabled is not true: a headless site serves no widgets.
  • delivery.frontend on a rendered site is an error (set delivery.mode: headless or remove it).
  • A public post endpoint’s respond is unused on a headless site, since it is posted through the API’s forms operation: a warning. You may leave respond out.

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
  end

For 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:

  1. 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.

  2. Read with a secret key holding preview, sending the token in the Meridian-Preview header:

    • 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}/preview answers the token’s document directly, with its page metadata, for a draft with no path.

    Both carry a preview block (document, locale, shared_revision, locale_revision, expires_at, substituted), are private, no-store, say noindex, and record no 404 miss.

  3. 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.

  4. Leave draft mode on preview_expired (401: past server.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 without preview), or not_found once 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.

EventWhen
content.publishedA locale of a document went live, including a scheduled publish
content.unpublishedA live locale was taken down
content.uri_changedA live document’s address changed
content.trashedA live document was trashed
content.restoredA document was restored from the trash
redirects.changedThe 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
ParameterMeaning
sinceA 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.
limitChanges 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. subject fills the endpoint’s path inputs (the listing an enquiry is about).
  • The challenge.token replaces the CSRF token. It is bound to the endpoint, dated for min_fill_ms, single use once the input is good, and accepted for twelve hours. Post honeypot’s field empty. When proof_of_work is present, find a proof such that sha256(token + proof) has at least difficulty leading 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 is 422 validation_failed with field_errors as {path, code, message}; posting faster than min_fill_ms is the same problem at _form. The endpoint’s own rate limit answers 429 rate_limited with Retry-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 forms and forward the visitor’s address in Meridian-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
Namesview (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_atRFC 3339, up to a day old and five minutes ahead; now when absent.
visitorThe visitor’s address is hashed under the day’s salt and discarded; the user agent feeds bot filtering.
LimitsAt most 100 events and 512 KiB a request; 20 requests a second per key.
Answer202 {"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.