QUESTPIE
Build Your WorkspaceLive Preview

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.

routes/pages/$slug.tsx
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:

PropertyTypeDescription
dataTDataCurrent draft data (applies patches locally)
isPreviewModebooleanWhether running inside the admin iframe
focusedFieldstring | nullField path the admin is currently editing
selectedBlockIdstring | nullBlock the admin has selected
seqnumberCurrent 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:

routes/pages/$slug.tsx
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:

admin/blocks/hero.tsx
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:

routes/articles/$slug.tsx
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:

routes/pages/$slug.tsx
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:

  1. Sends a PATCH_BATCH with { path: "slug", value: "about" }
  2. Updates the iframe src to "/about?preview=true" based on the collection URL builder
  3. 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

TypePayloadDescription
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

TypePayloadDescription
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-fetchesAdmin sends PATCH_BATCH, frame patches locally
No sequence numbersEvery message carries seq for ordering
No resync protocolFULL_RESYNC recovers from desync
handleFieldClick / handleBlockClick manuallyPreviewField / PreviewBlock handle it via context

The V1 hook continues to work for existing implementations. New projects should use V2 exclusively.

On this page