# Live Preview

Source: https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/collection-items) (RBAC JSON).

> **Related**
> [Collection items](https://docs.qiick.io/externa/collection-items) · [Content versioning](https://docs.qiick.io/externa/content-versioning) · [Public CMS API](https://docs.qiick.io/externa/public-cms-api) · [Headless starter](https://docs.qiick.io/externa/headless-starter) · [Outbound webhooks](https://docs.qiick.io/externa/webhooks)

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

| Token                         | Value                                                   |
| ----------------------------- | ------------------------------------------------------- |
| `{{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:

```text
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](https://docs.qiick.io/externa/collection-items) 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](https://docs.qiick.io/externa/public-cms-api-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](https://docs.qiick.io/externa/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](https://docs.qiick.io/externa/configuration#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.

```bash
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)

```json
{
  "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)

```json
{
  "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

> 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](https://github.com/qiick-io/externa-core/issues/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_of`                                      | Response                                                                |
| --------------------------------------------------------- | ----------------------------------------------------------------------- |
| `unpublish_at` is on or before `as_of`                    | **404** `Preview target not found.`                                     |
| Item has a draft and `publish_at` is on or before `as_of` | Draft values (`meta.source: "draft_overlay"`), `published_at` = `as_of` |
| Item is already published                                 | Current published values (`meta.source: "published"`)                   |
| Never published and no due publish                        | **404**                                                                 |

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

```json
{
  "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 Preview       | Preview as role              |
| -------- | ------------------ | ---------------------------- |
| Purpose  | Visual frontend    | RBAC field visibility (JSON) |
| Secret   | Signed `{{token}}` | Admin session                |
| Audience | Editors + frontend | Admins checking permissions  |

## Next starter

Wire `?token=` into [externa-next-starter](https://github.com/qiick-io/externa-next-starter) or your app; see [Headless starter](https://docs.qiick.io/externa/headless-starter).
