@conapps/cms-preview
v0.1.0
Published
Redeem and verify content preview tickets from the platform CMS. Framework-agnostic, with a click-to-edit bridge and a conformance doctor.
Maintainers
Readme
@conapps/cms-preview
Redeem and verify content preview tickets from the platform CMS, so editors can look at unpublished content — at a chosen point in time, for a chosen audience — on your real site.
Framework-agnostic. Works with Nuxt, Next, Astro, SvelteKit, Express, or anything else that can read a query string and set a cookie.
npm install @conapps/cms-previewThe idea in one paragraph
The CMS never renders your site. It hands your site a signed context — which revision, at which instant, in which locale, for which audience — and your site renders itself with it. That is the whole contract. The ticket is an ES256 JWT verified against the CMS's public JWKS, so you hold no secret, and it authorises nothing: your server still fetches content with its own delivery token.
Three steps
1. Register a preview target
In the backoffice under Content → Settings → Preview: your origin, the path
of your redemption route, and one or more URL patterns per content type
(/{locale}/blog/{fields.slug}). No code.
2. Add the redemption route
// Nuxt: server/api/preview.get.ts
import { PreviewHandler } from '@conapps/cms-preview'
const handler = new PreviewHandler({
cmsUrl: process.env.CMS_URL!, // https://cms.conapps.net
issuer: process.env.CMS_ISSUER!, // usually the same
siteOrigin: process.env.SITE_ORIGIN!, // https://mymovement.app
})
export default defineEventHandler(async (event) => {
const result = await handler.redeem(getRequestURL(event))
if (!result.ok) throw createError({ statusCode: result.status, message: result.message })
for (const [key, value] of Object.entries(result.headers)) appendResponseHeader(event, key, value)
return sendRedirect(event, result.location, 302)
})Next.js App Router:
// app/api/preview/route.ts
export async function GET(request: Request) {
const result = await handler.redeem(request.url)
if (!result.ok) return new Response(result.message, { status: result.status })
return new Response(null, { status: 302, headers: { Location: result.location, ...result.headers } })
}3. Pass the context to your content calls
const ticket = await handler.read(getRequestHeader(event, 'cookie'))
const params = ticket ? previewQueryParams(ticket.context) : {}
const entries = await $fetch(`${CMS_URL}/delivery/v1/spaces/${WS}/environments/master/entries`, {
headers: { authorization: `Bearer ${await deliveryToken()}` },
query: { content_type: 'blogPost', ...params },
})The part that goes wrong
Preview must not read or fill your public cache. This is the failure that costs something: the first preview writes a draft into the cache, and every visitor gets the unpublished text until it expires. No error, no log.
Do not extend the cache key — bypass the cached handler:
export const contentHandler = (key: string, load: Loader) => {
const cached = defineCachedEventHandler((e) => load(e, null), { maxAge: 300, swr: true, name: key })
return defineEventHandler(async (event) => {
const ticket = await handler.read(getRequestHeader(event, 'cookie'))
if (!ticket) return cached(event) // public path, unchanged
for (const [k, v] of Object.entries(previewResponseHeaders())) setResponseHeader(event, k, v)
return load(event, ticket.context) // preview: never cached
})
}A key that includes the context is correct only as long as it is correct, and the field people forget is the audience.
Checklist
Run it instead of reading it:
npx cms-preview doctor --site https://mymovement.app --cms https://cms.conapps.net --ticket <jwt>- [ ] Ticket verified against JWKS, with
issuerandaudience - [ ]
redirectrestricted to site-local paths (no//, no scheme) - [ ] Cookie is
HttpOnly,Secure,SameSite=None - [ ] Preview responses send
private, no-storeandX-Robots-Tag: noindex - [ ] Preview bypasses the route cache — neither reads nor fills it
- [ ] The rendered page is uncacheable in preview, not just the content endpoints
- [ ] Internal fetches forward the request's cookies (see below)
- [ ] A separate content client for preview, with caching off
- [ ]
content-management:previewon a separate API integration, not the public one - [ ]
Content-Security-Policy: frame-ancestors <backoffice-origin>
Two of these are worth spelling out:
SameSite=None — the preview runs in an iframe on the backoffice's origin,
which makes every request cross-site. With Lax it works when you open the page
in a tab and is empty in the frame.
A separate integration for the preview scope — if your public SSR client
carries content-management:preview, one bug turns drafts public. Without the
scope the Delivery API silently ignores ?preview=true, and the same bug is
harmless. The scope is additive: it widens an already read-authorised
request, so the integration needs content-management:read as well. On its own
it produces a 403 on every call.
Your framework's internal fetch probably drops the cookie. If your pages call
your own API routes (/api/content/...), check whether that call forwards the
incoming request's headers during SSR. Nuxt's $fetch does not — you need
useRequestFetch(). Next's fetch in a server component does not either; pass
headers() through. Without it the preview context never reaches the code that
would act on it, and the site quietly renders published content.
The page itself must be uncacheable too. Marking the content endpoints
no-store is not enough: the rendered HTML usually goes out with no
Cache-Control at all, and browsers then cache it heuristically. The symptom is
the worst kind — an edit shows up sometimes and not others, while the server is
answering correctly every time. A one-line server middleware that sets
private, no-store whenever the preview cookie is present covers every route at
once.
Click-to-edit (optional)
Ask the Delivery API for ?sourceMap=true, put the entries on your elements, and
start the bridge in preview mode only:
import { startPreviewBridge } from '@conapps/cms-preview/bridge'
startPreviewBridge({ backofficeOrigin: 'https://app.conapps.net' })<h1 :data-cms-source="JSON.stringify(entry.sys.sourceMap['fields.title'])">{{ title }}</h1>A parallel map rather than invisible characters embedded in your strings: markers
in the text survive copy-paste, break === and length, and end up in search
indexes.
API
| Export | Purpose |
|---|---|
| PreviewHandler | redeem(url) and read(cookieHeader) — the whole integration |
| PreviewVerifier | Ticket verification on its own |
| previewQueryParams(ctx) | Context → Delivery API query parameters |
| buildPreviewCookie / readCookie | Cookie with the right flags |
| isSafeRedirect / safeRedirect | Open-redirect guard |
| previewResponseHeaders() | no-store + noindex |
| runDoctor(options) | The checklist, programmatically |
| startPreviewBridge (/bridge) | Click-to-edit overlay |
Licence
MIT
