Preview
Delivery returns published content by default. Two things, and only two, open the draft view: being entitled to edit the content type, or presenting a signed grant from somebody who is.
The rule: you may preview what you may change
Section titled “The rule: you may preview what you may change”A draft read is gated on the content type’s update rule.
access: actions: read: { language: expr, expression: 'true' } update: { language: expr, expression: '"editor" in user.roles' }With this type, an editor may read ?status=draft. A reader with no editorial privilege may not.
This keeps one authorization tier: the answer comes from the entity’s own access rules, evaluated with the same bindings and the same evaluator a write uses, so a rule means the same thing everywhere.
The gate fails closed. A type with no update rule is a type nobody is declared able to edit, so
nobody can preview it either, including an administrator.
Why a front end needs a token
Section titled “Why a front end needs a token”The site rendering a preview holds a delivery credential for published content and no editorial rights. Giving it editorial rights so that it can render drafts would hand every visitor the editor’s access.
A preview token is the narrow grant that closes the gap. Someone who can already preview mints one scoped to exactly what they want shown, and it travels in the link they share.
Minting a token
Section titled “Minting a token”POST /app/:contentapp/preview-tokenContent-Type: application/json
{ "type": "posts", "space": "blog", "documentId": "01J9Y…", "locale": "en", "ttlSeconds": 900}| Field | Required | Meaning |
|---|---|---|
type | yes | Type name or plural. A grant must name a type |
space | no | Resolved from the type when omitted |
documentId | no | Narrows to one document. Absent means the whole type |
locale | no | Narrows to one locale. Absent means any |
ttlSeconds | no | Defaults to 1800. Clamped to 86400 |
{ "token": "kp1.eyJzcCI6…", "expiresAt": "2026-09-15T09:30:00Z", "type": "blog.post", "space": "blog", "documentId": "01J9Y…", "locale": "en"}Minting runs the same gate a direct draft read runs, so a token conveys access its issuer already had and never more.
| Status | When |
|---|---|
200 | Minted |
400 | No type in the body, or the body is not valid JSON |
403 | The caller may not preview this type, so they may not delegate it |
404 | No such type, or the plural is ambiguous across spaces |
501 | The app has no signing secret configured |
The 501 is deliberate. An app with no secret refuses rather than signing with an empty key: a
token that looks like it is protecting something and is not is worse than no preview at all. See
Operations.
Presenting a token
Section titled “Presenting a token”Two spellings, both accepted on both delivery surfaces.
GET /api/posts/01J9Y…?locale=en&preview=kp1.eyJzcCI6…GET /api/posts/01J9Y…?locale=enX-Preview-Token: kp1.eyJzcCI6…Prefer the header wherever the caller can set one. It stays out of logs, referrers and browser history, all of which a query string ends up in. The query parameter exists because a link an editor pastes into a browser cannot carry a header.
The token elevates a request; it does not authenticate one. The delivery routes still require the site’s ordinary credential, and a grant is never examined on a request that has not identified itself. The worst a leaked preview URL does is show its own narrow scope to somebody who could already read the published site.
Presenting a token is the request for drafts
Section titled “Presenting a token is the request for drafts”A link an editor pastes carries the token and nothing else, so requiring a second parameter would make every such link silently show published content.
An explicit status still wins:
?preview=kp1.… the draft view?preview=kp1.…&status=published the live version, same linkOn the Contentful surface, presenting a token selects the preview view in the same way.
What a grant covers
Section titled “What a grant covers”Every term is inside the signature, so the scope cannot be edited in the address bar. Widening a term invalidates the token rather than widening the grant.
| Term | Empty in the grant | Empty in the request |
|---|---|---|
type | not allowed | not applicable |
space | matches a spaceless request only | must match |
documentId | the whole type | does not satisfy a document-scoped grant |
locale | any locale | must match a locale-scoped grant |
The asymmetry in the last two rows is the point. A token minted for one document does not answer a request that names no document, because that request would return the whole type.
A locale-scoped grant does not authorise locale=*.
A token also names the app whose key signed it, and is held to it after verification, so it cannot be replayed against another app.
Lifetime
Section titled “Lifetime”| Default | 30 minutes |
| Maximum | 24 hours |
| Revocation | not available; a token is self-contained and lives out its term |
The token is payload plus HMAC with no server-side store, so there is nothing to replicate between instances and nothing to clean up. The short default lifetime is what bounds a leaked link.
Mint a fresh token per preview session rather than holding one open.
A worked front-end flow
Section titled “A worked front-end flow”- An editor opens the entry in your editing UI and clicks Preview.
- Your UI calls
POST /app/blog/preview-tokenwith the editor’s credential, naming the type, document and locale. - Your UI builds the preview URL for the site, carrying
?preview=<token>. - The site’s server-side render reads the token from its own query string and passes it on as
X-Preview-Tokenalongside the site’s delivery credential. - The delivery API returns the draft. When the token expires, the same URL returns the published
version, or
404if the document has never been published.
See also
Section titled “See also”- Strapi delivery:
?status=draft - Contentful delivery: the preview view
- The content model