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.
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
.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.
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.
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
| Topic | Page |
|---|---|
| The other four methods | Collections and globals |
| The form this pane sits beside | The form |
| Stacked content a person arranges | Blocks |
| Rows that exist before they go live | Options |
Next
Configuration covers the panel around these screens. The sidebar, the dashboard, branding and where the client mounts.
Actions
An action is a button in the panel with a handler on the server. It can ask for input first, confirm before it runs, and then invalidate, redirect or just say it worked.
Dashboard
Nine widget types under the dashboard key of config/admin.ts. Four read a collection for you. Three need a server loader you write.