Variation #2Continue reading...

Developer Demo

Draft Mode & Editorial Preview

Editors can preview unpublished content in the live app — including in-context editing via Visual Builder — without affecting what visitors see. Two separate systems work together: Next.js draft mode for cache bypass, and Optimizely's communicationinjector for real-time edit events.

force-dynamic /previewgetPreviewContent()communicationinjector.jsdata-epi-block-id

Two Content Modes #

The app serves content in two distinct modes depending on context. The caching strategy and Graph auth header change accordingly.

PublishedISR
Auth
epi-single {SINGLE_KEY}
Cache
next: { revalidate: 60 }

Default for all visitor traffic. Pages are pre-rendered and served from cache. Regenerated in the background after 60s or when a webhook fires.

Draft / Previewno-store
Auth
Bearer {previewToken}
Cache
cache: 'no-store'

Activated when the CMS calls /preview?preview_token=X&key=Y. getPreviewContent() fetches the latest draft version — unpublished changes visible only to the editor. The /preview route is force-dynamic and renders inside the CMS iframe when ctx=edit.

The Preview URL Flow #

The CMS is configured with a Preview URL pointing directly to /preview. When an editor clicks “Preview”, the CMS appends preview_token, key, and ctx automatically.

CMS editor clicks "Preview"
        │
        └─→ /preview?preview_token=<token>&key=<contentKey>&ctx=edit
                │
                ├─→ force-dynamic (never cached)
                ├─→ client.getPreviewContent(params) → draft content via previewToken
                ├─→ communicationinjector.js injected
                ├─→ <PreviewComponent /> mounted
                └─→ Render with data-epi-block-id attributes
                        │
                        └─→ Editor clicks any block → CMS panel highlights that property

/preview query params

// CMS is configured with Preview URL: https://your-app.com/preview
// It appends these query params automatically:

/preview?preview_token=<jwt>&key=<contentKey>&ctx=edit

// preview_token  — short-lived JWT issued by the CMS for this editor session.
//                  Passed to Graph as "Authorization: Bearer <token>" to
//                  fetch draft (unpublished) content instead of published.
// key            — UUID of the content item being previewed.
// ctx            — "edit" when opened inside the Visual Builder iframe;
//                  omitted for plain content preview.

graphqlFetch — cache bypass with previewToken

// src/lib/optimizely/client.ts
// When a previewToken is present, ISR is bypassed entirely

if (previewToken) {
  headers["Authorization"] = `Bearer ${previewToken}`;   // draft auth
} else {
  headers["Authorization"] = `epi-single ${SINGLE_KEY}`; // published auth
}

// Cache decision:
if (previewToken) {
  fetchOptions.cache = "no-store";   // always fetch fresh draft content
} else {
  fetchOptions.next = { revalidate: 60 };  // ISR for published content
}

The /preview Page #

getPreviewContent() handles all content types — experience pages, traditional pages, and shared blocks — and returns the item directly. OptimizelyComponent dispatches to the right React component by __typename, exactly as the published page does. No separate preview renderer needed.

// src/app/preview/page.tsx
export const dynamic = "force-dynamic";  // never statically generate this page

import { getClient, type PreviewParams } from "@optimizely/cms-sdk";
import { OptimizelyComponent, withAppContext } from "@optimizely/cms-sdk/react/server";
import { PreviewComponent } from "@optimizely/cms-sdk/react/client";

async function PreviewPage({ searchParams }) {
  const params = await searchParams;
  const client = getClient();

  // getPreviewContent reads preview_token, key, ver, ctx from query params,
  // fetches the draft version, and populates the withAppContext context store.
  // OptimizelyComponent dispatches to the right component by __typename —
  // same path as the published page, no separate preview renderer needed.
  const content = await client.getPreviewContent(params as PreviewParams);

  return (
    <>
      <Script src={`${CMS_URL}/util/javascript/communicationinjector.js`} strategy="afterInteractive" />
      <PreviewComponent />
      <OptimizelyComponent content={content} />
    </>
  );
}

export default withAppContext(PreviewPage);

The Preview Shell

Every preview render is wrapped in a “shell” that injects the two pieces the CMS needs to communicate with the page.

// The preview shell injects two things:
//
// 1. communicationinjector.js — the bridge between this page and the CMS
//    iframe. Without it, click-to-edit events from the CMS never reach the
//    page. It listens for postMessage events from the parent iframe and
//    dispatches them as DOM events.
//
// 2. <PreviewComponent /> (from @optimizely/cms-sdk/react/client) — a
//    client component that sends real-time edit notifications back to the
//    CMS so outline overlays update instantly as content is saved.

const shell = (children) => (
  <>
    <Script
      src={`${CMS_URL}/util/javascript/communicationinjector.js`}
      strategy="afterInteractive"
    />
    <PreviewComponent />
    {children}
  </>
);

data-epi-block-id

The contract between the frontend and the CMS overlay. The CMS reads this attribute to know which content item to highlight and which property panel to open when an editor clicks on the page.

// data-epi-block-id is the contract between the frontend and the CMS overlay.
// The SDK's getPreviewUtils() handles this — pa(node) spreads data-epi-block-id
// onto structural wrappers, and pa("propertyName") adds data-epi-edit to leaf elements.

// In BlankSection — pa(node) on row/column wrappers
function Row({ children, node }) {
  const { pa } = getPreviewUtils(node);
  return <div {...pa(node)}>{children}</div>;  // → data-epi-block-id={node.key}
}

// In DynamicExperience — ComponentWrapper wraps each composition component
function ComponentWrapper({ children, node }) {
  const { pa } = getPreviewUtils(node);
  return <div {...pa(node)}>{children}</div>;  // → data-epi-block-id={node.key}
}

// In block components — pa("propertyName") enables click-to-edit on fields
export default function HeroBlock({ content }) {
  const { pa } = getPreviewUtils(content);
  return (
    <section>
      <h1 {...pa("headline")}>{content.headline}</h1>   // → data-epi-edit="headline"
      <p  {...pa("subheadline")}>{content.subheadline}</p>
    </section>
  );
}

// getPreviewUtils reads the withAppContext context — pa() only emits attributes
// when the request was initiated via getPreviewContent() (i.e. in preview mode).
// On published pages it returns empty objects, adding zero DOM overhead.

Where communicationinjector.js Lives #

The script is injected on the /preview route only — not in the root layout. It is only needed when the page is rendered inside the CMS editor iframe, so keeping it scoped to /preview avoids loading it on every visitor page.

// communicationinjector.js is injected on the /preview route only.
// The root layout (src/app/layout.tsx) does NOT inject it — the script is
// only needed when the page is loaded inside the CMS editor iframe.

// src/app/preview/page.tsx
return (
  <>
    <Script
      src={`${process.env.NEXT_PUBLIC_OPTIMIZELY_CMS_URL}/util/javascript/communicationinjector.js`}
      strategy="afterInteractive"
    />
    <PreviewComponent />
    <OptimizelyComponent content={content} />
  </>
);

Setup Guide #

Environment variables

NEXT_PUBLIC_OPTIMIZELY_CMS_URL

Your CMS instance URL. Used to build the communicationinjector.js script URL on the /preview route.

OPTIMIZELY_GRAPH_SINGLE_KEY

Read-only key for published Graph queries. Already required for the main app.

OPTIMIZELY_GRAPH_GATEWAY

Graph gateway URL (default: https://cg.optimizely.com/content/v2). Passed to config() in componentRegistry.ts.

CMS admin configuration

  1. 1

    In CMS admin: Settings → Sites → select your site.

  2. 2

    Set the Preview URL to: https://your-app.com/preview

  3. 3

    The CMS will append ?preview_token=X&key=Y&ctx=edit automatically when an editor clicks Preview.

  4. 4

    For Visual Builder in-context editing: set NEXT_PUBLIC_OPTIMIZELY_CMS_URL so the /preview route can build the communicationinjector.js URL. No additional env var needed.