Features

Markdown

Live Preview

Live Preview opens your headless frontend for an item (draft or published) from the admin item form. It is not Preview as role (RBAC JSON).

Configure

  1. Collection (edit drawer) → Live Preview URL, or
  2. Settings → Content (/settings/project/content) → Live Preview URL (default) as fallback when the collection field is empty.

Template must be http(s)://… and may include tokens:

TokenValue
{{id}}Item id
{{slug}} / {{collection}}Collection slug
{{locale}}Optional content locale (omitted from query when empty)
{{version}}draft or published
{{token}}Short-lived HMAC preview token (~15 min)

Example:

https://www.example.com/preview/{{collection}}/{{id}}?version={{version}}&token={{token}}

Admin UX

On an existing item, the toolbar Live Preview button (external-link icon) calls GET /collections/{collection}/items/{item}/live-preview-url?version=draft|published (requires can-show-collections) and opens the expanded URL in a new tab.

Requires a configured template (collection or project default). Version follows the Draft/Published workspace when content versioning is on.

Frontend contract

Your site should:

  1. Read token (and optional version / locale) from the query
  2. GET {EXTERNA_API_URL}/api/v1/preview?token=…
  3. Render data (see response shapes below)

Auth with origin allowlist

The preview endpoint sits behind the Public API origin gate:

  • No allowlist (Settings → API → Allowed origins is empty): no key required.
  • With an allowlist: browser fetches from an allowlisted origin work without a key; server-side fetches (SSR, API routes, curl) have no Origin header and must send Authorization: Bearer <API key> (any active key). See Client types — Origin allowlist.

Do not put API keys in the preview URL template — keep {{token}} in the URL; add the Bearer key to server fetch headers only when an allowlist is set.

Draft vs published response shape

Published version (or no draft): same shape as a Public CMS item (Public CMS API). Translatable fields are flattened to the resolved locale; ?include= expands files and relations.

Draft version (versioned collection with a draft): returns raw draft_data without flattening or expansion:

  • data is keyed by field name
  • Translatable fields are per-locale maps ({ "en": "…", "it": "…" }); the locale claim and meta.locale do not flatten them
  • No file or relation expansion; no include support
  • No extra item attributes (status, published_at, etc.)

Frontend authors must normalize draft data themselves: pick the locale from the per-field map, then resolve file/relation ids through the Public CMS API.

Locale resolution

The admin toolbar button sends version but no locale ({{locale}} expands to empty and is dropped from the URL). The preview API resolves the locale like any Public CMS request:

  1. Token claim locale (if set)
  2. Request query ?locale= (frontend can add this)
  3. First Accept-Language language (if enabled)
  4. Project default_content_locale
  5. APP_LOCALE (if enabled)
  6. First enabled locale

An explicit ?locale= that is not enabled → 422. meta.locale in the response shows the resolved locale.

For published responses, this flattens translatable fields (value fallback: requested → fallback_content_locales → APP_FALLBACK_LOCALE if enabled → null; see Locale fallback). For draft responses, translatable fields stay as per-locale maps; the frontend should pick the locale using the same order or pass ?locale= on the preview API call.

curl -sS "http://externa-core.test/api/v1/preview?token=TOKEN" \
  -H 'Accept: application/json'
  # optional: -H "Authorization: Bearer ek_YOUR_SECRET"

Published response (or no draft)

{
  "data": {
    "id": 1,
    "collection_id": 3,
    "slug": "my-item",
    "data": {
      "title": "Hello World",
      "body": "<p>Content</p>"
    },
    "status": "published",
    "published_at": "2026-09-24T10:00:00Z",
    "created_at": "2026-09-20T08:00:00Z",
    "updated_at": "2026-09-24T09:00:00Z"
  },
  "meta": {
    "preview": true,
    "version": "published",
    "locale": "en",
    "expires_at": "2026-09-24T12:00:00Z"
  }
}

Draft response (versioned collection, has draft)

{
  "data": {
    "id": 1,
    "collection_id": 3,
    "data": {
      "title": { "en": "Draft Title", "it": "Titolo Bozza" },
      "cover_image": 42,
      "related_items": [10, 15]
    },
    "created_at": "2026-09-20T08:00:00Z",
    "updated_at": "2026-09-24T11:30:00Z"
  },
  "meta": {
    "preview": true,
    "version": "draft",
    "locale": "en",
    "expires_at": "2026-09-24T12:00:00Z"
  }
}

Errors

  • 422 token query parameter is required. — missing ?token=
  • 403 Invalid or expired preview token. — token malformed, expired, or bad HMAC
  • 403 Origin required or API key. — origin allowlist is set; server fetch needs Bearer key
  • 403 Origin not allowed. — browser Origin not in allowlist
  • 422 The locale :locale is not enabled for this project. — explicit ?locale= (or token claim) is not enabled
  • 404 Preview target not found. — item/collection deleted or id mismatch

Security

Tokens are HMAC-signed with APP_KEY and expire quickly. Anyone with a live token can read that item’s preview payload until expiry — treat preview URLs like temporary share links. Rotate APP_KEY only with full awareness of invalidated tokens.

Point-in-time preview (as_of)

From externa-core 1.4.0 (#222), a preview token can carry an optional as_of claim to show the item as it will look at a future moment, taking scheduled publish_at / unpublish_at into account.

  • Admin: a Preview at… date-time picker next to Live Preview calls GET /collections/{collection}/items/{item}/live-preview-url?version=…&as_of=<ISO 8601>.
  • Bound: as_of may be at most 90 days ahead; an unparseable or later value returns 422 on the URL endpoint.
  • Token only: as_of lives inside the signed token. The URL template is unchanged and needs no new placeholder.

When GET /api/v1/preview?token=… sees an as_of claim, it resolves the target item at that moment:

Situation at as_ofResponse
unpublish_at is on or before as_of404 Preview target not found.
Item has a draft and publish_at is on or before as_ofDraft values (meta.source: "draft_overlay"), published_at = as_of
Item is already publishedCurrent published values (meta.source: "published")
Never published and no due publish404

meta then also carries as_of (UTC, Z) and source; without the claim, meta.as_of is null.

{
  "data": {
    "id": 1,
    "collection_id": 3,
    "data": { "title": "Launch post" },
    "published_at": "2026-12-01T09:00:00+00:00"
  },
  "meta": {
    "preview": true,
    "version": "published",
    "locale": "en",
    "expires_at": "2026-11-03T12:00:00Z",
    "as_of": "2026-12-01T09:00:00Z",
    "source": "draft_overlay"
  }
}

Scope: only the token's target item is simulated. Related items and ?include= hydration show their current state, and the draft overlay returns flat draft_data without relation hydration.

vs Preview as role

Live PreviewPreview as role
PurposeVisual frontendRBAC field visibility (JSON)
SecretSigned {{token}}Admin session
AudienceEditors + frontendAdmins checking permissions

Next starter

Wire ?token= into externa-next-starter or your app; see Headless starter.