# Preview (/docs/admin/collections/preview)

---
title: Preview
description: 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.
kind: guide
package: "@questpie/admin"
---

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

```ts title="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.

<Callout type="info" title="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.
</Callout>

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

```tsx title="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.

| Route                | Does                                                                |
| -------------------- | ------------------------------------------------------------------- |
| `mintPreviewToken`   | Signs `{ path, exp }` for an admin. Returns `{ token, expiresAt }`. |
| `verifyPreviewToken` | Checks 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.

```ts
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.

<Callout type="warn" title="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.
</Callout>

## Where each topic lives

| Topic                               | Page                                               |
| ----------------------------------- | -------------------------------------------------- |
| The other four methods              | [Collections and globals](/docs/admin/collections) |
| The form this pane sits beside      | [The form](/docs/admin/collections/form)           |
| Stacked content a person arranges   | [Blocks](/docs/schema/blocks)                      |
| Rows that exist before they go live | [Options](/docs/schema/collections/options)        |

## Next

**[Configuration](/docs/admin/configuration)** covers the panel around these
screens. The sidebar, the dashboard, branding and where the client mounts.
