Same-Tab Recipe
Frontend implementation recipe for same-tab split-screen preview using direct postMessage patch transport.
This is the production-recommended approach for live preview. The admin panel opens a split-screen with your frontend in an iframe on the right. Changes flow as field-level patches over postMessage -- no server round-trip, no polling.
Minimal Page Example
Wire up preview in four lines: call useQuestpiePreview, wrap with PreviewRoot, annotate fields with PreviewField.
import { useRouter } from "@tanstack/react-router";
import {
useQuestpiePreview,
PreviewRoot,
PreviewField,
} from "@questpie/admin/client";
function PageRoute({ initialData }) {
const router = useRouter();
const preview = useQuestpiePreview({
initialData,
reconcile: () => router.invalidate(),
});
return (
<PreviewRoot preview={preview}>
<article>
<PreviewField path="title">
<h1>{preview.data.title}</h1>
</PreviewField>
<PreviewField path="summary">
<p>{preview.data.summary}</p>
</PreviewField>
</article>
</PreviewRoot>
);
}useQuestpiePreview returns a preview object containing:
| Property | Type | Description |
|---|---|---|
data | TData | Current draft data (applies patches locally) |
isPreviewMode | boolean | Whether running inside the admin iframe |
focusedField | string | null | Field path the admin is currently editing |
selectedBlockId | string | null | Block the admin has selected |
seq | number | Current sequence number |
Bootstrap Flow
When the admin opens live preview, the following handshake occurs:
Iframe loads your page
|
v
Frame sends READY ──────────────> Admin receives READY
| |
v v
Frame waits... Admin sends INIT_SNAPSHOT (full record)
| |
v v
Frame receives INIT_SNAPSHOT Admin enables editing
|
v
Frame applies initial data
Frame sends ACK { ackSeq }The hook handles this automatically. When your page mounts inside an iframe, it sends READY to the parent. The admin responds with INIT_SNAPSHOT carrying the full record. The hook stores this as the draft state and sends an ACK back.
Every message carries the protocol envelope:
{
sessionId: "preview_abc123",
seq: 42,
timestamp: 1711100000000,
protocolVersion: 2
}Patch Application
After bootstrap, every keystroke in the admin form produces a PATCH_BATCH message containing one or more field-level patches:
// Admin sends:
{
type: "PATCH_BATCH",
sessionId: "preview_abc123",
seq: 43,
timestamp: 1711100001000,
protocolVersion: 2,
patches: [
{ path: "title", value: "Updated Title" },
{ path: "slug", value: "updated-title" }
]
}The hook merges each patch into the local draft store by path and triggers a React re-render. No server call, no loader re-run -- just a local state update. The frame sends ACK with the received seq so the admin knows the patch was applied.
Admin types in "title" field
|
v
PATCH_BATCH { patches: [{ path: "title", value: "Updated T..." }] }
|
v
Frame merges patch into draft store
Frame re-renders
Frame sends ACK { ackSeq: 43 }Commit Flow
When the editor clicks Save, the admin persists to the database and sends COMMIT:
// Admin sends after successful save:
{
type: "COMMIT",
sessionId: "preview_abc123",
seq: 100,
timestamp: 1711100050000,
protocolVersion: 2,
data: { title: "Updated Title", slug: "updated-title", ... }
}The hook replaces the local draft store with the authoritative data from the COMMIT message, resolving any drift between optimistic patches and server-computed values. If you provide a reconcile callback, it is called after the state replacement -- use it to refetch route data or perform side effects:
const preview = useQuestpiePreview({
initialData,
reconcile: async () => {
// Re-run the loader to get server-authoritative data
await router.invalidate();
},
});After reconcile completes, the draft store resets to the fresh server data. The frame sends ACK to confirm.
Full Resync
If the admin detects a sequence gap or the frame falls behind, it sends FULL_RESYNC with the complete current state. This replaces the entire draft store:
Admin detects desync (missed ACK, seq gap, etc.)
|
v
FULL_RESYNC { data: { ...fullRecord }, seq: 150 }
|
v
Frame replaces entire draft store
Frame re-renders
Frame sends ACK { ackSeq: 150 }This is a recovery mechanism. Under normal operation, patches are small and incremental. Full resync only fires when something goes wrong -- browser tab was suspended, messages were dropped, or the admin explicitly triggers it.
Click-to-Edit
PreviewField and PreviewBlock make fields and blocks clickable. When the user clicks a field in the preview, a message is sent back to the admin to focus the corresponding form field.
Field Click
<PreviewField path="title">
<h1>{preview.data.title}</h1>
</PreviewField>Clicking this sends FIELD_CLICKED to the admin:
{
type: "FIELD_CLICKED",
fieldPath: "title",
sessionId: "preview_abc123",
seq: 44,
timestamp: 1711100002000,
protocolVersion: 2
}The admin scrolls to and focuses the title field in the form.
Block Click
<PreviewBlock id={block.id}>
<HeroRenderer values={block.values} data={block.data} />
</PreviewBlock>Clicking sends BLOCK_CLICKED:
{
type: "BLOCK_CLICKED",
blockId: "abc-123",
sessionId: "preview_abc123",
seq: 45,
timestamp: 1711100003000,
protocolVersion: 2
}Admin Focus Sync (Reverse Direction)
When the editor focuses a field in the admin form, the frame receives FOCUS_FIELD and highlights the corresponding element. When the editor selects a block, the frame receives SELECT_BLOCK.
The hook tracks these as preview.focusedField and preview.selectedBlockId. PreviewField and PreviewBlock apply highlight styles automatically when their path/ID matches.
Block Content Example
For pages with block-based content, wrap each block in PreviewBlock and use BlockScopeProvider for field path resolution inside blocks:
import {
useQuestpiePreview,
PreviewRoot,
PreviewField,
PreviewBlock,
BlockScopeProvider,
BlockRenderer,
} from "@questpie/admin/client";
import admin from "@/questpie/admin/.generated/client";
function PageRoute({ initialData }) {
const router = useRouter();
const preview = useQuestpiePreview({
initialData,
reconcile: () => router.invalidate(),
});
return (
<PreviewRoot preview={preview}>
<article>
<PreviewField path="title">
<h1>{preview.data.title}</h1>
</PreviewField>
{preview.data.content && (
<BlockRenderer
content={preview.data.content}
renderers={admin.blocks}
data={preview.data.content._data}
selectedBlockId={preview.selectedBlockId}
onBlockClick={
preview.isPreviewMode
? (blockId) => preview.sendBlockClicked(blockId)
: undefined
}
/>
)}
</article>
</PreviewRoot>
);
}Inside a block renderer, PreviewField auto-resolves paths using BlockScopeProvider. A field named "title" inside block abc123 resolves to content._values.abc123.title:
import { PreviewField } from "@questpie/admin/client";
import type { BlockProps } from "@questpie/admin/client";
export function HeroRenderer({ values, data }: BlockProps<"hero">) {
return (
<section className="relative min-h-[60vh] flex items-center justify-center">
{data?.backgroundImage?.url && (
<img
src={data.backgroundImage.url}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
)}
<div className="relative text-center">
<PreviewField path="title">
<h1 className="text-5xl font-bold">{values.title}</h1>
</PreviewField>
<PreviewField path="subtitle">
<p className="text-xl mt-4">{values.subtitle}</p>
</PreviewField>
<PreviewField path="ctaText">
<a href={values.ctaLink} className="mt-6 inline-block btn">
{values.ctaText}
</a>
</PreviewField>
</div>
</section>
);
}Relation Field Example
Relations (e.g., author, category) require fieldType="relation" so the admin knows to open the relation editor instead of a text field:
function ArticlePage({ initialData }) {
const router = useRouter();
const preview = useQuestpiePreview({
initialData,
reconcile: () => router.invalidate(),
});
return (
<PreviewRoot preview={preview}>
<article>
<PreviewField path="title">
<h1>{preview.data.title}</h1>
</PreviewField>
<PreviewField path="author" fieldType="relation">
<div className="flex items-center gap-3">
<img
src={preview.data.author?.avatar}
alt={preview.data.author?.name}
className="w-10 h-10 rounded-full"
/>
<span>{preview.data.author?.name}</span>
</div>
</PreviewField>
<PreviewField path="categories" fieldType="relation">
<div className="flex gap-2">
{preview.data.categories?.map((cat) => (
<span key={cat.id} className="px-2 py-1 bg-muted text-sm">
{cat.name}
</span>
))}
</div>
</PreviewField>
<PreviewField path="body">
<RichTextRenderer content={preview.data.body} />
</PreviewField>
</article>
</PreviewRoot>
);
}Clicking a relation field sends RELATION_CLICKED back to the admin, which opens the relation picker or navigates to the related record.
Slug/URL Watch with Server Reconcile
When the slug changes, the preview URL itself needs to update. The admin handles iframe URL changes automatically based on your collection's .preview({ url }) config. But your frontend may also need to refetch route-specific data when the slug changes.
Use reconcile with server-side revalidation:
import { useQuestpiePreview, PreviewRoot, PreviewField } from "@questpie/admin/client";
function PageRoute({ initialData }) {
const router = useRouter();
const params = Route.useParams();
const preview = useQuestpiePreview({
initialData,
reconcile: async () => {
// The admin already updated the iframe URL to the new slug.
// Invalidate the router to re-run the loader with the new params.
await router.invalidate();
},
});
return (
<PreviewRoot preview={preview}>
<PreviewField path="title">
<h1>{preview.data.title}</h1>
</PreviewField>
<PreviewField path="slug">
<p className="text-muted-foreground">/{preview.data.slug}</p>
</PreviewField>
</PreviewRoot>
);
}When the editor changes the slug from about-us to about, the admin:
- Sends a
PATCH_BATCHwith{ path: "slug", value: "about" } - Updates the iframe
srcto"/about?preview=true"based on the collection URL builder - The iframe navigates, re-runs the loader, and bootstraps a new preview session
Error Handling and Resync
The protocol includes error messages in both directions.
Admin-Side Error
If the admin encounters an error (e.g., validation failure on save), it sends ERROR:
{
type: "ERROR",
error: "Title is required",
code: "VALIDATION_FAILED",
sessionId: "preview_abc123",
seq: 101,
timestamp: 1711100060000,
protocolVersion: 2
}The hook exposes this through preview.error. Display it in your preview if needed:
function PageRoute({ initialData }) {
const preview = useQuestpiePreview({
initialData,
reconcile: () => router.invalidate(),
});
return (
<PreviewRoot preview={preview}>
{preview.error && (
<div className="bg-destructive/10 text-destructive p-4 text-sm">
{preview.error.error}
</div>
)}
<PreviewField path="title">
<h1>{preview.data.title}</h1>
</PreviewField>
</PreviewRoot>
);
}Frame-Side Error
If the frame encounters a render error, send RENDER_ERROR back to the admin:
{
type: "RENDER_ERROR",
error: "Failed to render block hero-1",
sessionId: "preview_abc123",
seq: 46,
timestamp: 1711100004000,
protocolVersion: 2
}The hook sends this automatically if a React error boundary catches an error inside PreviewRoot.
Requesting Resync
If the frame detects it is out of sync (e.g., received seq 50 but expected 48), the admin will detect the gap from the missing ACK and send a FULL_RESYNC automatically. No manual intervention is needed.
Message Reference
Admin to Frame
| Type | Payload | Description |
|---|---|---|
INIT_SNAPSHOT | { data } | Full initial record after handshake |
PATCH_BATCH | { patches: [{ path, value, seq }] } | Incremental field-level patches |
FOCUS_FIELD | { fieldPath } | Highlight a field in preview |
SELECT_BLOCK | { blockId } | Highlight a block in preview |
COMMIT | { data } | Server data is authoritative (saved) |
FULL_RESYNC | { data } | Replace entire draft state |
ERROR | { error, code? } | Admin-side error |
Frame to Admin
| Type | Payload | Description |
|---|---|---|
READY | {} | Frame loaded, awaiting snapshot |
ACK | { ackSeq } | Acknowledges received sequence |
FIELD_CLICKED | { fieldPath, blockId?, fieldType? } | User clicked a field |
BLOCK_CLICKED | { blockId } | User clicked a block |
RELATION_CLICKED | { fieldPath, relationId? } | User clicked a relation |
RENDER_ERROR | { error, seq? } | Frame-side render error |
All messages carry the envelope: sessionId, seq, timestamp, protocolVersion.
V1 to V2 Migration
The V2 API (useQuestpiePreview) replaces the V1 API (useCollectionPreview). Key differences:
V1 (useCollectionPreview) | V2 (useQuestpiePreview) |
|---|---|
onRefresh callback (full reload) | reconcile callback (only on COMMIT) |
Admin sends PREVIEW_REFRESH, frame re-fetches | Admin sends PATCH_BATCH, frame patches locally |
| No sequence numbers | Every message carries seq for ordering |
| No resync protocol | FULL_RESYNC recovers from desync |
handleFieldClick / handleBlockClick manually | PreviewField / PreviewBlock handle it via context |
The V1 hook continues to work for existing implementations. New projects should use V2 exclusively.
Related Pages
- Live Preview -- Overview and collection configuration
- Shared Preview -- Detached preview via realtime
- Blocks -- Block content editing
- Block Renderers -- Client-side block components