Editors want to see a change on the real storefront before they publish it. A custom view in the Crystallize App frames your storefront next to the item being edited, with a device toolbar for mobile and desktop widths. This page shows the storefront's side: a preview route that turns on preview mode, draft reads from the Discovery API, no caching, noindex, and the headers and cookies that let the App frame the preview.
noindex.The App draws everything around the preview: the device toolbar, the reload button and the split view. The storefront needs no preview interface of its own.
Add the view in the Crystallize App under Settings → Custom views. Custom views describes the dialog and how editors open a view. The URL points at your preview route:
https://your-store.example/api/preview?secret=YOUR_PREVIEW_SECRET&language={{language}}&itemId={{itemId}}&path={{itemPath}}https://your-store.example/api/preview?secret=YOUR_PREVIEW_SECRET&language={{language}}&itemId={{itemId}}&path={{itemPath}}The App replaces these placeholders for the item that is open:
Placeholder | Value |
|---|---|
| The language the editor is working in, such as |
| The item id. It is the same in every language and survives renames, so resolve by it |
| The item's path in the catalogue tree. It has no language prefix and is translated per language |
| The item's external reference, if your routes use it |
| The selected variant's SKU, if your product page selects a variant from the URL |
Turn on "Show responsive device toolbar in preview" for every custom view. Editors then switch between mobile and desktop widths in the App's toolbar. Your storefront only has to be responsive, which it already is: don't add device buttons, a frame or a preview banner of your own.
The secret is stored in the tenant's settings, where anyone who can open them can read it. Generate one only for preview, and keep it in a server environment variable such as PREVIEW_SECRET.
Custom views are tenant preferences. They can't be fully set up through the API: the device toolbar setting is only available in the App, so add views there.
The route checks the secret, finds the item's storefront URL, turns on preview mode and redirects. In Next.js, preview mode is Draft Mode:
// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { createHash, timingSafeEqual } from 'node:crypto';
const digest = (s: string) => createHash('sha256').update(s).digest();
export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
const secret = params.get('secret');
if (!process.env.PREVIEW_SECRET || !secret || !timingSafeEqual(digest(secret), digest(process.env.PREVIEW_SECRET))) {
return new Response('Invalid preview secret', { status: 401 });
}
// Redirect only to a path Discovery returned, never to a URL parameter.
const target = await previewTarget(params.get('language'), params.get('itemId'));
if (!target) return new Response('No storefront page for this item in this language', { status: 404 });
(await draftMode()).enable();
redirect(target);
}// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { createHash, timingSafeEqual } from 'node:crypto';
const digest = (s: string) => createHash('sha256').update(s).digest();
export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
const secret = params.get('secret');
if (!process.env.PREVIEW_SECRET || !secret || !timingSafeEqual(digest(secret), digest(process.env.PREVIEW_SECRET))) {
return new Response('Invalid preview secret', { status: 401 });
}
// Redirect only to a path Discovery returned, never to a URL parameter.
const target = await previewTarget(params.get('language'), params.get('itemId'));
if (!target) return new Response('No storefront page for this item in this language', { status: 404 });
(await draftMode()).enable();
redirect(target);
}The path in the catalogue tree is not your storefront URL. Storefront URLs usually start with the language (/no/...), and paths are translated, so the Norwegian page has a different path from the English one. Look the item up by id, in the editor's language, and read the draft: a renamed or new item has its new path only in the draft.
query PreviewTarget($id: String!) {
search(language: en, publicationState: draft, filters: { itemId: { equals: $id } }, pagination: { limit: 1 }) {
hits { path externalReference }
}
}query PreviewTarget($id: String!) {
search(language: en, publicationState: draft, filters: { itemId: { equals: $id } }, pagination: { limit: 1 }) {
hits { path externalReference }
}
}async function previewTarget(language: string | null, itemId: string | null) {
const lang = isLanguage(language) ? language : 'en';
if (!itemId) return null;
const data = await discoveryDraft(PREVIEW_TARGET, { id: itemId }, lang);
const hit = data.search.hits[0];
if (!hit) return null;
// Items with routes of their own: the home page is /{lang}, not /{lang}/pages/home.
return hit.externalReference === 'page:home' ? `/${lang}` : `/${lang}${hit.path}`;
}async function previewTarget(language: string | null, itemId: string | null) {
const lang = isLanguage(language) ? language : 'en';
if (!itemId) return null;
const data = await discoveryDraft(PREVIEW_TARGET, { id: itemId }, lang);
const hit = data.search.hits[0];
if (!hit) return null;
// Items with routes of their own: the home page is /{lang}, not /{lang}/pages/home.
return hit.externalReference === 'page:home' ? `/${lang}` : `/${lang}${hit.path}`;
}If you accept a path without an id, look it up in every language: the App's path can be in another language than the one the editor works in.
Add an exit route, such as /api/preview/exit, that calls (await draftMode()).disable() and redirects to a path on your own site. Use POST from a form. If you link to a GET exit route, turn prefetching off on the link, or a prefetch ends preview mode before anyone clicks.
In preview mode every Discovery read asks for drafts. Add publicationState: draft next to language: every Discovery field that takes a language also takes it, including search, autocomplete, every browse field and the nested items and variants relation fields. One rewrite covers a whole query:
export const draftQuery = (query: string) =>
query.replace(/\((\s*language:\s*[^,)\s]+)(?![^)]*publicationState)/g, '($1, publicationState: draft');export const draftQuery = (query: string) =>
query.replace(/\((\s*language:\s*[^,)\s]+)(?![^)]*publicationState)/g, '($1, publicationState: draft');Put the switch inside the cached Discovery client from Caching and revalidation. With Cache Components, draftMode() can be read inside 'use cache'. It is off while pages are prerendered and for every request without the preview cookie, so normal traffic stays cached and static. With the cookie, Next.js runs every cached function again on each request and saves nothing:
async function cached<T>(query: string, variables: Record<string, unknown>): Promise<T> {
'use cache: remote';
if ((await draftMode()).isEnabled) return request<T>(draftQuery(query), variables);
cacheLife('hours');
cacheTag('discovery');
return request<T>(query, variables);
}async function cached<T>(query: string, variables: Record<string, unknown>): Promise<T> {
'use cache: remote';
if ((await draftMode()).isEnabled) return request<T>(draftQuery(query), variables);
cacheLife('hours');
cacheTag('discovery');
return request<T>(query, variables);
}Your storefront's own pages are then the preview. There is no separate preview route to keep in step with the real pages.
On an open Discovery API, publicationState: draft needs no credentials: anyone who knows your tenant identifier can read every draft. The preview secret only decides who gets preview mode: uncached pages, noindex and framing by the App. If drafts must stay private, restrict Discovery and send the static auth token from your server (see The storefront API contract).
Custom views asks preview pages to load the App's listener script. Load it in preview mode only:
async function PreviewListener() {
const { isEnabled } = await draftMode();
return isEnabled ? <script defer src="https://app.crystallize.com/static/frontend-preview-listener.js" /> : null;
}async function PreviewListener() {
const { isEnabled } = await draftMode();
return isEnabled ? <script defer src="https://app.crystallize.com/static/frontend-preview-listener.js" /> : null;
}The script tells the App the page has loaded, and reloads the page when the App reports that the item was saved.
Cache-Control: private, no-cache, no-store and skips every cache. In other frameworks, send no-store on preview responses yourself, and never let a CDN cache a page without the preview cookie in its key.robots: { index: false, follow: false } in the root layout's metadata when draftMode() is on, and send X-Robots-Tag: noindex, nofollow on preview responses. draftMode() is off during prerendering, so the published pages keep their normal robots tags./api/, which robots.txt disallows (see Crawlers and sitemaps), and nothing in the store links to it.Most storefronts send X-Frame-Options and frame-ancestors so no other site can frame them, and that also blocks the Crystallize App. Allow the App only where it needs it: on the preview route, and on responses to a browser in preview mode. The App runs on https://app.crystallize.com. In Next.js, match on the Draft Mode cookie in next.config:
async headers() {
const preview = "frame-ancestors 'self' https://app.crystallize.com";
const draftCookie = [{ type: 'cookie' as const, key: '__prerender_bypass' }];
return [
{
source: '/:path((?!api/preview(?:/|$)).*)',
missing: draftCookie,
headers: [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Content-Security-Policy', value: "frame-ancestors 'self'" },
],
},
{
source: '/:path*',
has: draftCookie,
headers: [
{ key: 'Content-Security-Policy', value: preview },
{ key: 'X-Robots-Tag', value: 'noindex, nofollow' },
],
},
{
source: '/api/preview/:path*',
headers: [
{ key: 'Content-Security-Policy', value: preview },
{ key: 'X-Robots-Tag', value: 'noindex, nofollow' },
],
},
];
}async headers() {
const preview = "frame-ancestors 'self' https://app.crystallize.com";
const draftCookie = [{ type: 'cookie' as const, key: '__prerender_bypass' }];
return [
{
source: '/:path((?!api/preview(?:/|$)).*)',
missing: draftCookie,
headers: [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Content-Security-Policy', value: "frame-ancestors 'self'" },
],
},
{
source: '/:path*',
has: draftCookie,
headers: [
{ key: 'Content-Security-Policy', value: preview },
{ key: 'X-Robots-Tag', value: 'noindex, nofollow' },
],
},
{
source: '/api/preview/:path*',
headers: [
{ key: 'Content-Security-Policy', value: preview },
{ key: 'X-Robots-Tag', value: 'noindex, nofollow' },
],
},
];
}Matching on the cookie is safe: a browser that sends a made-up cookie only makes pages framable by the Crystallize App, for itself.
The preview cookie is set and read inside the App's frame, which makes it a third-party cookie:
Lax or Strict cookie is never sent inside the frame, and the preview silently shows published content. Next.js sets its Draft Mode cookie this way in production builds, but uses Lax in next dev, so test the framed preview against a production build over HTTPS.enable() and set it again with partitioned: true, and delete it in the exit route with the Partitioned attribute too./{lang}/... and sets the preview cookie with Secure, SameSite=None and Partitioned.frame-ancestors https://app.crystallize.com, has no X-Frame-Options, is noindex and loads the listener script. Without the cookie it keeps X-Frame-Options: SAMEORIGIN and its normal robots tags.publicationState: draft on every request. A normal visit afterwards is still served from the cache.Symptom | Cause | Fix |
|---|---|---|
The view is blank or says the site refused to connect |
| Allow |
The preview shows published content, but only inside the App | The preview cookie is |
|
Preview works in English and fails in other languages | The route redirected to the tree path as it came | Resolve the item id per language and add the language prefix |
A renamed or new item is not found | The URL was resolved from published content | Resolve with |
Every page became dynamic | Preview mode checked with | Check |
Preview pages appear in search results | No | Robots metadata from |
The view doesn't follow the editor's changes | The App's listener script isn't loaded | Load |
The Tools Universe demo store implements this page: a preview route with per-language resolution, draft reads in the cached Discovery client, and framing only in preview mode.