QUESTPIE
AdminCollections

Preview

A live preview puts the page a row renders as in an iframe beside the form that edits it. One url builder turns it on, and a hook on the front end makes it update as somebody types.

View markdown

How does an editor see what a change looks like before they save it? Give the collection a function that turns a row into a URL. The panel opens that URL next to the form.

Turn it on

src/questpie/server/collections/posts.ts
.preview({
	url: ({ record, locale }) => `/${locale ?? "en"}/blog/${record.slug}`,
})

That is the whole config. url is what enables it. The enabled key exists to turn it off again, as enabled: false, without deleting the builder.

A Preview button now appears on /admin/collections/posts/:id. It splits the screen. The form takes the left half and your page fills an iframe on the right, with a drag handle between them. On a phone there is no room to split, so the same button opens the page in a new tab.

Edit screens only

Preview needs a saved row. It is absent on the create screen, because there is no id yet and nothing to render.

The url builder runs on the server

It never ships to the browser. Introspection sends the client one boolean, saying a builder exists. The panel posts the row to getPreviewUrl. That route calls your function and returns the string.

So the builder may read whatever the server can reach. It receives { record, locale }. record is the row as the form currently holds it. locale is the content locale the editor is looking at. Returning a relative path is normal. The iframe resolves it against the panel's own origin.

The route requires session.user.role === "admin", so an anonymous caller cannot mine your URL scheme.

Making it live

An iframe alone reloads on every save. To make it update while somebody types, the page inside the frame opts in.

app/routes/blog.$slug.tsx
import {
	PreviewField,
	PreviewProvider,
	useCollectionPreview,
} from "@questpie/admin/client";

export default function BlogPost() {
	const { post } = Route.useLoaderData();
	const preview = useCollectionPreview({
		initialData: post,
		onRefresh: () => router.invalidate(),
	});

	return (
		<PreviewProvider preview={preview}>
			<article>
				<PreviewField field="title" as="h1">
					{preview.data.title}
				</PreviewField>
			</article>
		</PreviewProvider>
	);
}

preview.data starts as initialData and then follows the form. The hook talks to the panel over postMessage, so it needs no network of its own. preview.isPreviewMode is true only inside the frame. That is how one component serves the real page and the preview.

PreviewField needs PreviewProvider above it. Without the provider it renders its children and nothing else. With it, clicking a value jumps the form to that field. Add editable="text" or editable="textarea" and the editor can retype the value on the page. It lands back in the form.

PreviewBanner, BlockScopeProvider and useResolveFieldPath come from the same import when you need them.

Drafts

A preview of unpublished content needs the front end to fetch drafts. So the request has to prove it came from the panel. Two routes handle that.

RouteDoes
mintPreviewTokenSigns { path, exp } for an admin. Returns { token, expiresAt }.
verifyPreviewTokenChecks a token. Returns { valid, path }.

A token is HMAC-SHA256 over the payload, keyed with your app secret. It lasts an hour unless you pass ttlMs. Minting requires an admin session. Verifying requires nothing, because the token is the proof.

Set secret. The app reads it from QUESTPIE_SECRET, then from BETTER_AUTH_SECRET. With neither one set the app has no secret, and preview tokens are signed with the literal string dev-preview-secret. Anyone can mint one.

You write the route that spends it. It verifies, sets a cookie, and redirects to the path from the payload.

import { createDraftModeCookie, isDraftMode } from "@questpie/admin/shared";

createDraftModeCookie(true) returns a __draft_mode Set-Cookie string, path /, HttpOnly, SameSite Lax, one hour by default. isDraftMode(cookieHeader) answers whether a request carries it, so a loader can decide which rows to fetch.

The token names a path, not a row

Verification tells you the path the token was minted for. Nothing stops a holder from reading any draft your loader will serve while the cookie is set. Keep the TTL short.

Where each topic lives

TopicPage
The other four methodsCollections and globals
The form this pane sits besideThe form
Stacked content a person arrangesBlocks
Rows that exist before they go liveOptions

Next

Configuration covers the panel around these screens. The sidebar, the dashboard, branding and where the client mounts.

On this page