Preview your site in the admin
The admin can show the pages of your own site, in the selection an editor is working in: a draft, a content release or an experiment, for a market and language. The editor hovers and clicks content on the page, edits the entity and field it comes from next to it, and the page refreshes as the edits are saved.
Your site takes part with the Vox web SDK, @commerce-mind/vox-web. It
- marks the elements of a page with the projection data they render,
- talks to the admin when the page is shown in the admin's preview,
- helps your server pass the editor's preview grant to Vox and answer preview requests safely.
Outside a preview it does nothing. The example site src/examples/preview-site shows everything on this page with
plain Node, and its brand page with React.
How it fits together
- The editor opens Preview in the admin. The admin asks Vox for a preview session, which returns the URL of the
entity's page on your site, with a short-lived, signed grant in the
vox-previewquery parameter, and shows the URL in a frame. - Your server reads the grant from the request and sends it to Vox as
Vox-Preview: {grant}, next to its own API key, on every request for the page. Vox then reads the editor's draft or release instead of published content. - Your page renders as usual, with a few attributes that name the projection data each element shows, and loads the SDK.
- The SDK says hello to the admin. The admin fetches what the marked data is made of, labels it, and lets the editor select it. After a save, it tells the page to refresh.
A grant alone authenticates nothing: Vox only accepts it with the API key of a client that has the preview permission.
Install the SDK
npm install @commerce-mind/vox-web
| Entry | Use it in |
|---|---|
@commerce-mind/vox-web | the browser: the runtime and the markup helpers |
@commerce-mind/vox-web/server | your server: grants, headers and the markup of a request |
@commerce-mind/vox-web/react | React and frameworks built on it, eg Next.js |
@commerce-mind/vox-web/script | a page without a bundler: one script file to serve yourself |
The package has no dependencies. React is an optional peer dependency of ./react.
Give the site an API client
Your site reads Vox with an API client's key, Authorization: ApiKey {clientId}.{secret}. For previews, the client
needs, next to its read permissions, the preview permission of each restricted context type editors preview in:
vox:Preview:$draftfor drafts,vox:Preview:$releasefor content releases,vox:Preview:{contextType}for other restricted context types, eg an experiment.
A preview permission allows reading that context type with a grant, without any permission to edit or publish. See Users and permissions.
The host also maps each route space to your site's origin when it registers preview, eg storefront → https://www.northpeak.example, so the admin knows where a path opens.
Mark up the page
An element names a projection key and a JSON path in that projection's value. Vox maps them back to entities and fields; your site never needs to know the entity model.
| Attribute | Means |
|---|---|
data-vox-source="{key}#{path}" | the element renders the value at path, eg name, price or hero.items[2].title |
data-vox-entity="{key}" | the element renders a whole projection, eg a product card or a block |
data-vox-text | the element's text is exactly that string, so the admin can update it while typing |
The key is the projection's key in the delivery response: the response's key, or the key of a referenced projection
in its projections graph. Paths use the delivered JSON names, dots and [i]. Several sources on one element are
separated by a space.
You don't write the attributes yourself. The helpers return them as an object to spread, and {} outside a preview:
voxText(node, path)for a plain string field, eg a name,voxSource(node, path)for anything else, eg rich text, a formatted price or an image,voxEntity(node)for the root of a page and for cards and blocks.
node is a delivery response, a node of its projections graph (its key is computed), or a key string:
<main {...voxEntity(page)}>
<h1 {...voxText(page, 'name')}>{page.value.name}</h1>
<p {...voxSource(page, 'price')}>{formatPrice(page.value.price)}</p>
<div {...voxSource(page, 'description')} dangerouslySetInnerHTML={{ __html: page.value.description }} />
{accessories.map(card => (
<a key={card.key} href={card.value.path} {...voxEntity(card.key)}>
<span {...voxText(card.key, 'name')}>{card.value.name}</span>
</a>
))}
</main>
Mark a value where the page got it. A product card that shows the name of its brand gets it from the card's
brandName, so mark it voxText(card.key, 'brandName'); Vox knows that it comes from the brand and opens the brand
when it's selected.
Which helpers to use
The helpers must only add attributes on preview requests. Where they come from decides how they know:
- On the server,
voxMarkup(request)from./serverreturns{ voxSource, voxEntity, voxText }bound to whether the request has a grant. - In React,
useVoxMarkup()from./reactreturns the helpers of the surrounding<VoxPreview>. - In the browser,
voxSource,voxEntityandvoxTextfrom.add attributes when the page's URL has a grant. createVoxMarkup(enabled)returns helpers bound to whatever you decide.
For template strings, renderVoxAttributes renders the attributes as HTML:
import { renderVoxAttributes as attrs, voxMarkup } from '@commerce-mind/vox-web/server';
const { voxText } = voxMarkup(request);
const html = `<h1${attrs(voxText(page, 'name'))}>${escapeHtml(page.value.name)}</h1>`;
projectionKey({ entityType, entityId, projection, contexts }) computes a key, eg for a $ref, with exactly the
format Vox uses, eg vox://entity/Product/p1?projection=card&context=Language:sv.
Handle preview requests on the server
import {
isVoxPreviewExpired,
readVoxPreview,
voxPreviewExpiredMarkup,
voxPreviewHeaders,
voxPreviewResponseHeaders,
} from '@commerce-mind/vox-web/server';
const grant = readVoxPreview(request); // the vox-preview parameter, or null
const response = await fetch(`${voxUrl}/content${url.pathname}`, {
headers: { Authorization: `ApiKey ${apiKey}`, ...voxPreviewHeaders(grant) },
});
if (isVoxPreviewExpired(response)) {
// Render a page with voxPreviewExpiredMarkup in its <head>, and the SDK
}
const headers = grant ? voxPreviewResponseHeaders({ adminOrigins }) : publicHeaders;
- Send
Vox-Previewon every request to Vox for the page, whether it reads by route, by id or with a query, so the whole page is in the preview. - Answer preview responses with
voxPreviewResponseHeaders: only the admin may frame them (Content-Security-Policy: frame-ancestors), they're never cached (Cache-Control: private, no-store) or indexed (X-Robots-Tag: noindex), and their URL, which has the grant, isn't sent as a referrer. - Don't let your public pages be framed, eg
Content-Security-Policy: frame-ancestors 'none'. - Keep the grant out of your logs and analytics: log paths without the query. The helpers never log it.
- Leave the markup and the SDK out of public pages, with
isVoxPreview(request).
Expired grants
A grant lives for 30 minutes by default, and the admin renews it in the background. When a request still arrives with
an expired or invalid grant, Vox answers 401 with WWW-Authenticate: VoxPreview error="expired" (or "invalid"),
which isVoxPreviewExpired recognizes. Render a page with voxPreviewExpiredMarkup
(<meta name="vox-preview" content="expired">) and the SDK: it tells the admin, which sends a new grant, and the page
reloads with it. A page that finds out in the browser calls reportExpired() on the SDK's handle instead.
Load the SDK in the page
With a bundler, start the runtime in the browser:
import { startVoxPreview } from '@commerce-mind/vox-web';
startVoxPreview({
adminOrigins: ['https://admin.northpeak.example'],
delivery: 'direct',
});
Without one, serve @commerce-mind/vox-web/script (dist/vox-web.iife.js) and include it in preview responses only;
voxPreviewScript({ src, adminOrigins, delivery }) from ./server renders the tag:
<script
src="/vox-web.iife.js"
data-admin-origins="https://admin.northpeak.example"
data-delivery="direct"
defer
></script>
adminOriginsare the origins of your admin. The SDK only talks to them and to the window that frames the page; it never takes an origin from the URL or a message. Several are separated by spaces indata-admin-origins.deliveryisdirectwhen your server reads projections from the Vox API, which evaluates them when they're read, andpublishedwhen it reads them from its own store, written by a publication with restricted content. The admin then waits until the publication has processed a save before it refreshes the page.
The runtime only starts in a frame whose URL has the grant; anywhere else it returns an inactive handle and does nothing.
What the editor sees
With "Highlight content" on, hovering a marked element outlines it with a label from the admin, eg "Product · Name".
The innermost marked element wins; with Alt, its marked parent. Clicking selects it, instead of following a link or
running the page's own click handling, and Esc clears the selection. The outlines are drawn in a shadow root above
the page, so your CSS can't affect them, and they don't affect your layout. With highlighting off, the page behaves
normally. When the editor focuses a field in the panel, the page scrolls to its elements and outlines them.
Navigation inside the preview stays in the preview: the SDK adds the grant to same-origin links, form submissions and
history.pushState, and reports each new page to the admin, which follows it.
The SDK sends the admin which keys and paths are on the page and where the editor points, never content or the grant.
Refresh after a save
After a save, the admin tells the page what changed. By default the SDK reloads the page and keeps the scroll position.
An application that can refetch does so with onChange, eg in Next.js:
'use client';
import { VoxPreview } from '@commerce-mind/vox-web/react';
import { useRouter } from 'next/navigation';
export function Preview({ preview, children }: { preview: boolean; children: React.ReactNode }) {
const router = useRouter();
return (
<VoxPreview
adminOrigins={['https://admin.northpeak.example']}
delivery="direct"
preview={preview}
onChange={() => router.refresh()}
>
{children}
</VoxPreview>
);
}
onChange receives { keys, entities }, the projection keys on the page that changed and the saved entities, if you
want to refetch less.
While the editor types in a plain string field, the admin also sends the new text, and the SDK replaces the text of the
data-vox-text elements of that key and path whose text is still the previous value, so the page follows the typing
before the save.
React
<VoxPreview> starts the runtime once, in an effect, so it renders on the server like any other component. Pass
preview from the server, eg isVoxPreview(request), so the server and the browser render the same attributes;
without it, the browser decides after hydration. Inside it:
useVoxMarkup()returns{ voxSource, voxEntity, voxText },useVoxPreview()returns{ isPreview, selection }, the admin's selection once the preview started, eg{ Language: ['sv'], $draft: ['$'] }.