Skip to main content

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​

  1. 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-preview query parameter, and shows the URL in a frame.
  2. 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.
  3. Your page renders as usual, with a few attributes that name the projection data each element shows, and loads the SDK.
  4. 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
EntryUse it in
@commerce-mind/vox-webthe browser: the runtime and the markup helpers
@commerce-mind/vox-web/serveryour server: grants, headers and the markup of a request
@commerce-mind/vox-web/reactReact and frameworks built on it, eg Next.js
@commerce-mind/vox-web/scripta 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:$draft for drafts,
  • vox:Preview:$release for 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.

AttributeMeans
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-textthe 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 ./server returns { voxSource, voxEntity, voxText } bound to whether the request has a grant.
  • In React, useVoxMarkup() from ./react returns the helpers of the surrounding <VoxPreview>.
  • In the browser, voxSource, voxEntity and voxText from . 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-Preview on 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>
  • adminOrigins are 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 in data-admin-origins.
  • delivery is direct when your server reads projections from the Vox API, which evaluates them when they're read, and published when 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: ['$'] }.