Developer Demo
Draft Mode & Editorial Preview
Two Content Modes #
The app serves content in two distinct modes depending on context. The caching strategy and Graph auth header change accordingly.
epi-single {SINGLE_KEY}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.
Bearer {previewToken}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.
// 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_URLYour CMS instance URL. Used to build the communicationinjector.js script URL on the /preview route.
OPTIMIZELY_GRAPH_SINGLE_KEYRead-only key for published Graph queries. Already required for the main app.
OPTIMIZELY_GRAPH_GATEWAYGraph gateway URL (default: https://cg.optimizely.com/content/v2). Passed to config() in componentRegistry.ts.
OPTIMIZELY_APP_KEY / _SECRETApp 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_SECRETAny 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
In CMS admin: Settings → Sites → select your site.
- 2
Set the Preview URL to: https://your-app.com/preview
- 3
The CMS will append ?preview_token=X&key=Y&ctx=edit automatically when an editor clicks Preview.
- 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.