Preview a draft entry
The public delivery API only ever serves status='published' entries, so a draft is normally invisible to the frontend. Preview links close that gap: cms_get_preview_link mints a signed, entry-scoped token valid for 1 hour, and the single-entry delivery route accepts it as ?preview=<token>, returning the entry regardless of status with Cache-Control: no-store (plus a status field so the frontend can render a draft banner). List and search routes never accept preview tokens — the preview unit is one entry.
Munin cannot render the customer's frontend itself; it delegates. The collection stores a previewUrl template pointing at the frontend's preview endpoint, and Munin substitutes the entry's coordinates plus the token into it.
TL;DR
- Once per collection: set
settings.previewUrlviacms_update_collection(read-merge-write — see warning below). - Per preview:
cms_get_preview_link { id }→ open the returnedurl(ordeliveryUrlfor raw JSON when no template is set). - Once per frontend: a draft-mode route handler that accepts the token and re-fetches with
?preview=. - Once per frontend, to preview inside the dashboard's Review pane: let the dashboard origin frame the preview routes, and set the preview cookie
SameSite=None; Secure(see Step 4 — both fail silently otherwise).
Step 1 — configure the collection's preview template
settingsis replaced wholesale, not merged. Always read the current settings first and send the merged object back, or you will wipe other keys such assearchableFields.
{ "name": "cms_get_collection", "arguments": { "idOrSlug": "blog-posts" } }
Then write back the existing settings plus the template:
{
"name": "cms_update_collection",
"arguments": {
"idOrSlug": "blog-posts",
"patch": {
"settings": {
// ...every key the collection already had, plus:
"previewUrl": "https://www.example.com/api/preview?token={token}&slug={slug}&locale={locale}"
}
}
}
}
Placeholders — each substituted value is URL-encoded:
| Placeholder | Substituted with |
|---|---|
{token} | the signed preview token |
{slug} | the entry's slug |
{locale} | the entry's locale |
{collection} | the collection's slug |
The substituted result must be a valid http(s) URL; minting fails with a 400 otherwise.
Step 2 — mint a link
{ "name": "cms_get_preview_link", "arguments": { "id": "<entryId>" } }
Returns:
{
"url": "https://www.example.com/api/preview?token=pv1....&slug=my-post&locale=en",
"deliveryUrl": "https://api.example-tenant.com/v1/cms/org_x/blog-posts/my-post?locale=en&preview=pv1....",
"token": "pv1....",
"expiresAt": "2026-07-26T11:00:00.000Z"
}
urlis what a human opens — it isnullwhen the collection has nopreviewUrltemplate.deliveryUrlis the raw delivery-API JSON for the draft; useful for verifying content without a frontend.- Tokens expire after 1 hour, and a link stops resolving if the entry's slug changes — mint a fresh one in either case. Any status is previewable (draft, scheduled, archived, published).
Step 3 — the frontend's side of the contract
The frontend needs one preview endpoint that flips it into draft mode and one change to its entry fetch. Next.js (app router) example:
// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(req: Request) {
const params = new URL(req.url).searchParams;
const token = params.get('token');
const slug = params.get('slug');
const locale = params.get('locale') ?? 'en';
if (!token || !slug) return new Response('missing token or slug', { status: 400 });
(await draftMode()).enable();
(await cookies()).set('munin-preview-token', token, {
httpOnly: true,
secure: true,
sameSite: 'none',
partitioned: true,
path: '/',
});
redirect(`/${locale}/blog/${slug}`);
}
// in the entry page's server-side fetch
const { isEnabled } = await draftMode();
const token = isEnabled ? (await cookies()).get('munin-preview-token')?.value : undefined;
const res = await fetch(
`${API_URL}/v1/cms/${ORG_ID}/blog-posts/${slug}?locale=${locale}` +
(token ? `&preview=${encodeURIComponent(token)}` : ''),
token ? { cache: 'no-store' } : { next: { revalidate: 60 } },
);
Notes:
- The fetch stays server-side — the delivery API has no CORS headers by design (
skill://playbooks/frontend-integration). Draft mode's own bypass cookie does not carry the Munin token, hence the extra cookie. sameSite: 'none'on that cookie is not optional if the preview is ever opened in the Review pane — see Step 4.- Preview responses include
status; render a visible "draft" banner when it isn'tpublished. - Under a preview token,
?include=referencesresolves inlineref://tokens against unpublished entries too, so a draft that links to another draft previews as the author meant it. The live route only ever resolves published targets — a link that works in preview can still be missing once the entry goes live if its target is still a draft. - An expired or tampered token returns 403 (never a silent fallback to the published version); a slug mismatch returns 404. Surface these rather than swallowing them — they mean "mint a new link".
Step 4 — let the Review pane embed it
The dashboard's Review pane renders the preview in an <iframe> next to the approve/dismiss actions, appending munin_embed=1 to the URL so the frontend can drop its own chrome (cookie banner, nav, chat widget) for the embedded view. Two things on the frontend decide whether that frame shows anything, and both fail silently in the browser — a blocked frame fires load exactly like a successful one and exposes nothing to the embedder, so the pane cannot tell you what went wrong from the browser alone. Munin probes the preview URL server-side and names the offending header in the pane instead.
1. Framing headers must allow the dashboard origin. A site that sends Content-Security-Policy: frame-ancestors 'none' or X-Frame-Options: DENY — a very common default, and what Next.js security-header snippets usually suggest — cannot be embedded anywhere. Scope the exception to the preview routes rather than the whole site:
// next.config.mjs
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
{ key: 'X-Frame-Options', value: 'DENY' },
],
},
{
source: '/api/preview',
headers: [
{ key: 'Content-Security-Policy', value: 'frame-ancestors https://app.example.com' },
],
},
{
source: '/:lng/blog/:slug',
headers: [
{ key: 'Content-Security-Policy', value: 'frame-ancestors https://app.example.com' },
],
},
];
}
Replace https://app.example.com with the origin the Munin dashboard is served from. Do not re-send X-Frame-Options on those routes — it has no allowlist (ALLOW-FROM is dead and ignored by every current browser), so the only way to permit one embedder is to omit the header and let frame-ancestors decide. Browsers ignore X-Frame-Options when a CSP frame-ancestors directive is present, but Safari has not always, so leave it off rather than relying on that.
2. The preview cookie must survive a third-party context. The dashboard is a different site from the frontend, so the frame is cross-site: a cookie set SameSite=Lax (the browser default when the attribute is omitted) is not sent back on the redirect inside the frame. The preview endpoint sets the cookie, the frame redirects, the cookie never arrives, and the reader gets the published page — or a 404 — with no error anywhere. Next's own draft-mode bypass cookie already uses SameSite=None in production for exactly this reason; the Munin token cookie has to match:
(await cookies()).set('munin-preview-token', token, {
httpOnly: true,
secure: true,
sameSite: 'none', // required: the dashboard frames this cross-site
partitioned: true, // CHIPS — third-party cookies are blocked without it
path: '/',
});
sameSite: 'none' requires secure: true, so preview only works over HTTPS. partitioned: true keys the cookie to the embedding site, which is what keeps it working as browsers finish phasing out unpartitioned third-party cookies — and is harmless where they haven't.
To check both from the outside:
curl -sSI 'https://www.example.com/api/preview?token=…&slug=…&locale=en' \
| grep -i 'content-security-policy\|x-frame-options\|set-cookie'
You want frame-ancestors naming the dashboard origin (or no framing headers at all), no X-Frame-Options, and SameSite=None; Secure on the preview cookie. When the frame cannot be embedded, the Review pane falls back to the field view and says which header refused — the "open on the site" link keeps working either way, because a top-level navigation is not framed.
What NOT to do
- Don't write
settings.previewUrlwithout merging the collection's existing settings (see Step 1). - Don't put the preview token in client-side fetches or localStorage. It belongs in an httpOnly cookie and server-side requests only.
- Don't try to preview a list page. Tokens authorize exactly one entry; list and search routes ignore drafts unconditionally.
- Don't leave the preview cookie on the default
SameSite. It reads as working when you open the link in a tab and fails only inside the Review pane's frame, where it shows the published entry instead of the draft. - Don't answer a blank preview pane by widening the site's framing headers. Scope
frame-ancestorsto the preview routes and to the dashboard origin;frame-ancestors *lets anyone frame the site.
Related
skill://cms/publish-entry— the publish/schedule/rollback loop once the preview looks right.skill://cms/localize-entry— per-locale entries; each locale row is its own entry and needs its own preview link.skill://playbooks/frontend-integration— full frontend wiring (widget + analytics + CMS delivery).