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
                ├─→ <NextPreviewComponent /> 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. SDK docs ↗

// 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 { NextPreviewComponent } from "@optimizely/cms-sdk/react/nextjs";

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" />
      <NextPreviewComponent />
      <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. <NextPreviewComponent /> (from @optimizely/cms-sdk/react/nextjs) - a
//    client component that listens for content-saved events from the CMS
//    and soft-refreshes via the Next.js router. (The framework-agnostic
//    <PreviewComponent /> from react/client does the same with a full
//    window.location navigation, or a custom onNavigate callback.)

const shell = (children) => (
  <>
    <Script
      src={`${CMS_URL}/util/javascript/communicationinjector.js`}
      strategy="afterInteractive"
    />
    <NextPreviewComponent />
    {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. SDK docs ↗

// 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"
    />
    <NextPreviewComponent />
    <OptimizelyComponent content={content} />
  </>
);

Sharing a Preview Externally #

The preview_token the CMS appends is a ~5 minute JWT and there is no way to extend it or issue a stable one - so it cannot be handed to someone outside the editor. For a shareable link, the app fetches the draft with the App Key + Secret (Graph treats these as a super-user, with no expiry) and signs the URL with its own secret so the content key cannot be tampered with.

No stable preview token

The CMS preview_token always expires in ~5 min. Neither Optimizely's API nor the community SDK can mint a longer-lived one.

App Key + Secret = super-user

Basic/HMAC auth on the Graph delivery endpoint returns content of any publish status and never expires. Server-side only.

Signed, revocable links

The URL holds only key/loc/ver + an HMAC signature. Links never expire; rotating OPTIMIZELY_PREVIEW_SECRET invalidates every one.

Fetching a draft without the ephemeral token
// The CMS preview_token is a ~5-minute JWT and cannot be extended - Optimizely
// provides no API to mint a stable one. To let a stakeholder with no CMS login
// open a draft, authenticate to Graph with the App Key + Secret instead:
// "querying with HMAC is equivalent to querying as a super user ... returns all
// content regardless of publication status" - and it never expires.

// src/lib/optimizely/adminPreviewClient.ts - a GraphClient whose request() is
// patched to send Basic auth (App Key + Secret) rather than Bearer <preview_token>.
Authorization: `Basic ${btoa(`${OPTIMIZELY_APP_KEY}:${OPTIMIZELY_APP_SECRET}`)}`

// The shareable URL carries NO Graph credential - only key / loc / (ver) plus an
// HMAC-SHA256 signature (src/lib/preview/shareLink.ts), so a recipient cannot
// edit the query string to pull a different content key.
/preview/share?key=<key>&loc=en&ver=<n>&sig=<hmac>

// src/app/preview/share/page.tsx verifies the signature, resolves the version
// (pinned, or newest by _metadata.lastModified), fetches the draft with the
// admin client, and renders it read-only - no communicationinjector, no
// NextPreviewComponent. Links stay valid until OPTIMIZELY_PREVIEW_SECRET rotates.

The toolbar at the top of the /preview route has a Share link button that copies a link pinned to the version being previewed (the route also accepts a version-less link that resolves the newest version). The external route (/preview/share) renders read-only - no edit overlays, no communicationinjector.

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.

OPTIMIZELY_APP_KEY / _SECRET

App Key + Secret. Used server-side as Basic auth to fetch drafts for external preview links (super-user, no token expiry). Already present for webhooks / Content Source API.

OPTIMIZELY_PREVIEW_SECRET

Any random string. Signs the /preview/share links so the content key cannot be tampered with. Rotating it invalidates every outstanding external preview link. Unset = the Share link control is hidden.

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.