Wocart

Preview

Preview lets a merchant edit in Wocart Studio and see the result on the real storefront: the same components, the same store data and the same CSS a shopper gets. The merchant changes a value; the page in the frame re-renders.

A theme opts in with preview: true and serves a route at /wocart-preview. The manifest tells Wocart Studio that the route exists.

If your theme is a fixed Astro design and nobody edits it from a dashboard, you do not need this page.

The three parts

The theme starter has all three (src/middleware.ts, src/pages/wocart-preview.astro, src/components/surface/PreviewBridge.astro); this section explains them.

1. The request

Wocart Studio opens the route in a frame:

Code
GET /wocart-preview?token=…&surface=home[&locale=fr][&path=/products/robe]

and re-renders by posting the draft back to the same route, from the framed page itself:

Code
POST /wocart-preview Content-Type: application/json { "token": "…", "draft": { "surface": "home", "locale": "fr", "document": { … } } }

readPreviewRequest(request, { publicKey, theme }) from @wocart/storefront/server reads either form. It answers { ok: true, claims, draft, token } only when the token is signed by the deployment's preview key, was made for this storefront origin and this theme key, is for preview (not revalidation), and has not expired (a token lives at most an hour). Otherwise it answers { ok: false, status } and the theme renders nothing:

StatusWhen
404No preview key is configured: preview is off
403The token is missing or refused, or a draft was posted from another origin
400The body or the draft is malformed
405Neither GET nor POST
413 / 415The draft is over 1 MB, or not JSON

The draft (PreviewDraft) names what to show and carries what changed; every part is optional and what is absent renders as saved:

Draft fieldMeaning
surfaceThe surface edited: home, product, an area (area:header) or a landing page (lp:<id>)
localeThe language to render, when the store serves it
pathFor a template, the resource route it previews (/products/robe)
documentThe draft page document
settingsDraft theme settings, replacing the saved ones
contentDraft text edits per language, replacing those languages' saved edits

The middleware reads the request and builds the draft's store. createStorefrontMiddleware does it when given the preview route and key: it checks the token, builds the store with resolvePreviewStore (the draft's settings and content over the saved config, the Meta pixel off), sets locals.preview to { claims, draft, token }, and never runs a form action.

TypeScriptCode
// src/middleware.ts export const onRequest = createStorefrontMiddleware({ client: createStore, fallback: unavailableStoreConfig({ serverUrl, locale: DEFAULT_LOCALE }), preview: { path: THEME_PREVIEW_PATH, publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY }, context: (store) => buildStoreContext(store.config, store.locale, store.pathname, store.client), getActionContext, });

The route answers with previewResponseHeaders(claims): only the token's editor may frame it, and no browser, CDN or search engine keeps it. No route rule may cache it.

2. The markers

<InstanceView preview …> brackets each instance's nodes with two HTML comments:

Code
<!--wocart:start id=home-hero type=hero--> <section>…</section> <!--wocart:end id=home-hero-->

Comments, not wrapper elements, so a grid or a > * selector lays out exactly as it does for a shopper. A hidden instance keeps its markers with nothing between them. A shopper's page carries none. Ids and types are URI-encoded, so no saved value can close the comment.

The helpers are previewStartMarker, previewEndMarker and parsePreviewMarker.

3. The bridge

startPreviewBridge({ editorOrigin, token }) from @wocart/theme/preview runs on the preview page only. It:

  • reports every marked instance and where it is to Wocart Studio,
  • turns clicks into selections in edit mode,
  • re-renders the drafts Studio sends, by posting them back to the route,
  • swaps in only the sections whose markup changed, or the whole page when the page around them changed.

It draws nothing. Outlines, toolbars and panels belong to Studio. Pass it the verified token's editor claim — the only origin it will post to or listen to — and the token itself:

Code
<wocart-preview-bridge data-editor={claims.editor} data-token={token} hidden></wocart-preview-bridge> <script> import { startPreviewBridge } from "@wocart/theme/preview"; const host = document.querySelector<HTMLElement>("wocart-preview-bridge"); if (host?.dataset.editor && host.dataset.token) { startPreviewBridge({ editorOrigin: host.dataset.editor, token: host.dataset.token }); } </script>

A draft is always posted as JSON from the page's own origin — never a cross-site form — so Astro's origin check stays on.

The protocol

Every message is { source, version, type, … }. The page sends source: "wocart-preview"; Studio sends source: "wocart-editor". Both sides post with the other's exact origin, and check event.origin and event.source before reading a message.

PREVIEW_PROTOCOL_VERSION is 1.

The editor sendsThe page answers
—ready with every instance: { id, type, parent, hidden, rect }
render { draft, revision? }rendered { revision, instances }, or error { revision, reason }
patch { patches }the change at once, then layout
mode "edit" or "interactive"in edit, clicks select the innermost instance; in interactive, links and forms work
scroll-to { id }the instance scrolls into view
token { token }the next render uses it
—hover { id | null } while the pointer moves; layout after a scroll or resize

A render answered after a newer render — or after a patch — is dropped. The newest draft always wins.

Patches

A patch is a hint that lets the page show a change before the slow render arrives. The page applies what it can find and leaves the rest to the render, which is the page's truth. An editor always sends the render too, so a page that ignores a patch still shows every change a moment later.

PatchWhat it shows
move { id, index, parent? }An instance at an index among its siblings
duplicate { id, ids }A copy right after it; ids maps every original id to its copy
remove { id }The instance and everything inside it gone
restore { id, index, parent? }What a remove took back, at an index
hide { id, hidden }The instance shown or switched off
text { id, from, to }Text inside an instance changed

Browser behavior in a section

A swapped section's scripts do not run again; the browser never executes a script inserted through markup. Bind a section's behaviour with bindEach from @wocart/theme/lifecycle: it binds each matching element once — on load, after Astro's router swaps the page, and after the preview swaps a section in — and runs the teardown it returned when the element leaves:

TypeScriptCode
import { bindEach } from "@wocart/theme/lifecycle"; bindEach<HTMLElement>("[data-countdown]", (element) => { const timer = setInterval(() => tick(element), 1000); return () => clearInterval(timer); });

When the bridge replaces the whole page, it does what Astro's router does after a navigation — a new <body>, the root element's attributes, the title — and dispatches astro:page-load, so a page's behaviour mounted with onEachPage mounts again as it does there. Changed stylesheets are replaced in place; unchanged ones never reload. A theme never listens for the swap events itself.

What a preview shows

previewTarget(theme, draft, client) decides it once for every theme. A template previews the resource the draft's path names (/products/robe), else the store's first product, category, default collection or text page — and a store with none of them still previews, the template empty so its sections can be arranged. An area (the header, the footer) previews on the page its path names when the theme composes that page, else on the home page. The resource is chosen by the surface's pageType, so an alternate template previews like its base. A landing page's own document (lp:<id>) previews with the theme's landing surface on the page its path names (/lp/<slug>); there is no "first landing page" to fall back on, and the target keeps the lp: surface, so the sections render that page's document. A surface the theme does not declare is null: answer 404.

Code
--- import { previewTarget } from "@wocart/theme/astro"; const preview = Astro.locals.preview; const refuse = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } }); if (preview === undefined) return refuse(); const target = await previewTarget(theme, preview.draft, Astro.locals.store.client); if (target === null) return refuse(); --- <Sections theme={theme} surface={target.surface} document={target.document} params={target.params} preview={target.area === null} />
PreviewTarget fieldMeaning
surfaceThe surface the page renders: the draft's own, or for an area the page it is shown on
pageTypeThat surface's page type
areaThe area being edited (area:header), or null when the page itself is edited
params{ handle } for a product or collection, { slug } for a category, page or landing page — empty when the store has none yet
documentThe draft's document when the page is edited, else the store's saved one

Mark the page's sections only when the page is edited (target.area === null); while an area is edited, the layout marks the area's sections instead, so only what is being edited is selectable.

Security

ControlEffect
Signed, expiring tokenOnly a real editor session can open a preview
previewResponseHeadersOnly that editor can frame it; it is not stored
POST with JSON for a new draftNo cross-site form; checkOrigin stays on
Exact editorOriginThe bridge posts nowhere else
event.origin and event.source checksOnly the framing editor is heard
source and version checksAn old or foreign message is ignored
Markers only in previewA shopper's page contains no preview metadata

A preview is never cached. It may contain an unsaved draft, a draft price or a draft product that a shopper must not see.