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
and re-renders by posting the draft back to the same route, from the framed page itself:
Code
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:
| Status | When |
|---|---|
404 | No preview key is configured: preview is off |
403 | The token is missing or refused, or a draft was posted from another origin |
400 | The body or the draft is malformed |
405 | Neither GET nor POST |
413 / 415 | The 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 field | Meaning |
|---|---|
surface | The surface edited: home, product, an area (area:header) or a landing page (lp:<id>) |
locale | The language to render, when the store serves it |
path | For a template, the resource route it previews (/products/robe) |
document | The draft page document |
settings | Draft theme settings, replacing the saved ones |
content | Draft 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.
Code
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
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
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 sends | The 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.
| Patch | What 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:
Code
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
PreviewTarget field | Meaning |
|---|---|
surface | The surface the page renders: the draft's own, or for an area the page it is shown on |
pageType | That surface's page type |
area | The 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 |
document | The 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
| Control | Effect |
|---|---|
| Signed, expiring token | Only a real editor session can open a preview |
previewResponseHeaders | Only that editor can frame it; it is not stored |
| POST with JSON for a new draft | No cross-site form; checkOrigin stays on |
Exact editorOrigin | The bridge posts nowhere else |
event.origin and event.source checks | Only the framing editor is heard |
source and version checks | An old or foreign message is ignored |
| Markers only in preview | A 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.