Crystallize logo

Previewing drafts in the storefront with custom views

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.

How preview works

  1. The editor opens an item in the Crystallize App and picks your custom view. The App fills in the view's URL template for that item and loads it in a frame.
  2. Your preview route checks the secret, looks up the item's storefront URL in the editor's language, turns on preview mode with a cookie, and redirects.
  3. In preview mode your normal pages read drafts from the Discovery API, skip every cache and are marked noindex.
  4. When the editor saves, the App tells the framed page to reload, and the view shows the new draft.

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.

Set up the custom view

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}}

The App replaces these placeholders for the item that is open:

Placeholder

Value

{{language}}

The language the editor is working in, such as en or no

{{itemId}}

The item id. It is the same in every language and survives renames, so resolve by it

{{itemPath}}

The item's path in the catalogue tree. It has no language prefix and is translated per language

{{itemExternalReference}}

The item's external reference, if your routes use it

{{variantSku}}

The selected variant's SKU, if your product page selects a variant from the URL

tip

Always turn on the responsive device toolbar

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 preview route

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);
}

Resolve the storefront URL per language

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 }
  }
}
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.

Leave preview mode

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.

Read drafts in preview mode

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');

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);
}

Your storefront's own pages are then the preview. There is no separate preview route to keep in step with the real pages.

warning

The secret protects preview mode, not your drafts

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).

Reload when the editor saves

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;
}

The script tells the App the page has loaded, and reloads the page when the App reports that the item was saved.

Framing and cookies

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' },
      ],
    },
  ];
}

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:

  • It must be `SameSite=None; Secure`. A 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.
  • Add `Partitioned` so browsers that block third-party cookies keep it for the App's site. A partitioned cookie also keeps the editor's own visits to the store, outside the App, out of preview mode. In Next.js, read the cookie after enable() and set it again with partitioned: true, and delete it in the exit route with the Partitioned attribute too.
  • Safari may refuse cookies in the frame under its tracking prevention. Tell editors which browsers the preview supports.

Check it

  1. Open the preview route with a wrong secret: it answers 401. With the right one, it redirects to /{lang}/... and sets the preview cookie with Secure, SameSite=None and Partitioned.
  2. Request a page with the cookie: it allows 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.
  3. Log the Discovery requests: with the cookie, every query carries publicationState: draft on every request. A normal visit afterwards is still served from the cache.
  4. In the App, open an item, pick the custom view, switch between mobile and desktop, save a change and watch the view reload.

Common problems

Symptom

Cause

Fix

The view is blank or says the site refused to connect

X-Frame-Options or frame-ancestors 'self' on preview responses

Allow https://app.crystallize.com on the preview route and in preview mode

The preview shows published content, but only inside the App

The preview cookie is SameSite=Lax, or blocked as a third-party cookie

SameSite=None; Secure; Partitioned, tested on a production build

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 publicationState: draft

Every page became dynamic

Preview mode checked with cookies() or headers() in a shared layout

Check draftMode(), which is off during prerendering

Preview pages appear in search results

No noindex in preview mode

Robots metadata from draftMode(), X-Robots-Tag on preview responses

The view doesn't follow the editor's changes

The App's listener script isn't loaded

Load frontend-preview-listener.js in preview mode

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.