Skip to content

Let editors preview drafts on your site

Composer’s Live preview shows an editor’s draft, unsaved edits included, rendered by your own site’s code. Nothing is published while they work.

A site connects in one of two ways, chosen per host under Settings › Sites › Live preview:

Mode For How the draft reaches the page
Bridge A site that renders in the browser with @ebitex/content-sdk/react, including a server-rendered app that hydrates Composer posts the resolved draft into the framed page. The site never holds a credential that can read drafts.
Server A site that renders every page on its server and runs no content code in the browser Composer hands the server a preview session, and the server reads the draft from the API with its own key.

Either way, your site has to let Composer frame it. Send this response header from wherever your site sets headers:

Content-Security-Policy: frame-ancestors 'self' https://content.ebitex.io

Without it, the browser blocks the frame and Composer reports that the site didn’t answer.

Wrap the part of your app that renders a page in <PreviewBridge>, inside <ContentProvider>:

preview.tsx
// Live preview: the bridge mounted inside the provider, a renderer opting into field pins, and the
// framework-agnostic receiver for a site that isn't React.
import { createContentClient } from '@ebitex/content-sdk'
import { createPreviewReceiver, isPreviewActivated, type PreviewSource } from '@ebitex/content-sdk/preview'
import { ContentProvider, Experience, PreviewBridge, usePreviewField, usePreviewMode, usePreviewSource, type PresentationRenderer } from '@ebitex/content-sdk/react'
declare const DELIVERY_KEY: string
declare const pathname: string
declare const renderers: Record<string, PresentationRenderer>
const content = createContentClient({ apiKey: DELIVERY_KEY })
interface HeroContent {
headline: string
intro: string
}
const Hero: PresentationRenderer<HeroContent> = ({ component }) => {
const field = usePreviewField() // {} outside preview — production markup is unchanged
const { active } = usePreviewMode()
const source: PreviewSource | undefined = usePreviewSource()
return (
<section data-previewing={active} data-entity={source?.entity.id}>
<h1 {...field('headline')}>{component.content.headline}</h1>
<p {...field('intro')}>{component.content.intro}</p>
<footer>© Acme</footer>
</section>
)
}
export const hero = Hero
export function App() {
return (
<ContentProvider client={content} renderers={renderers}>
<PreviewBridge origins={['https://content.ebitex.io', 'http://localhost:5176']} path={pathname}>
<Experience path={pathname} notFound={<p>Not found</p>} />
</PreviewBridge>
</ContentProvider>
)
}
// A non-React site: the same protocol, by hand.
if (isPreviewActivated(window.location, window)) {
const receiver = createPreviewReceiver({
origins: ['https://content.ebitex.io'],
onDocument: (message) => {
document.title = `Draft · ${message.result.kind === 'presentation' ? message.result.path : ''}`
receiver.postRendered(message.seq, [])
},
})
receiver.ready()
}

<PreviewBridge> renders its children unchanged unless two things are true: the page was framed or opened by another window, and its URL carries ?ebitex-preview=1. An ordinary visit, or a preview URL pasted into a new tab, never activates it. Once activated, the bridge:

  • tells Composer it is ready, and accepts drafts only from origins: exact scheme://host[:port] values, https://content.ebitex.io by default;
  • renders each draft through <Experience result> in place of its children, using your renderers;
  • reports path as your router changes it, so an editor in Browse mode can follow links.

While the bridge is active but no draft has arrived yet, <Experience path> renders its loading element and makes no request. Pass a loading element you are happy for an editor to see for a moment.

In Edit mode the bridge draws numbered pins over the page. Every Presentation gets a pin with no work from you. To give a single field its own pin, spread usePreviewField() onto the element that renders it, as the Hero renderer above does. Outside a preview it returns an empty object, so your production markup does not change. Clicking a field’s pin opens that field in Composer.

Markup inside a Presentation that carries no mark is shown to the editor as belonging to the Template, not to a field. usePreviewMode() and usePreviewSource() tell a renderer whether a preview is active and which entity it is rendering, if it wants to behave differently.

A header or footer your site fetches by external id is not part of any page. To preview it too, give the bridge scope="app" and wrap the whole app, then render each such component through <StandaloneComponent>:

standalone.tsx
// Content outside the Experience tree: a site's header, fetched once by external id and rendered by
// an ordinary renderer, previewed via <StandaloneComponent> once a <PreviewBridge scope="app">
// wraps the whole app instead of just one page. Memoizing the fetch is left to you; this shows
// only the SDK surface it's built from.
import { createContentClient, type ComponentValue } from '@ebitex/content-sdk'
import { ContentProvider, Experience, PreviewBridge, StandaloneComponent, usePreviewField, type PresentationRenderer } from '@ebitex/content-sdk/react'
declare const DELIVERY_KEY: string
declare const pathname: string
declare const renderers: Record<string, PresentationRenderer>
const content = createContentClient({ apiKey: DELIVERY_KEY })
interface HeaderContent {
links: Array<{ content: { label: string; link: { url: string | null } } }>
}
declare const header: ComponentValue<HeaderContent> | null | undefined
function Header({ value }: { value: ComponentValue<HeaderContent> | null | undefined }) {
const field = usePreviewField()
const links = value?.content?.links ?? []
return (
<nav>
{links.map((link, i) => (
<a key={i} href={link.content.link.url ?? '#'} {...field(`links.${i}`)}>
{link.content.label}
</a>
))}
</nav>
)
}
export function App() {
return (
<ContentProvider client={content} renderers={renderers}>
{/* scope="app": children always render, so a header outside any one page's own root still previews */}
<PreviewBridge origins={['https://content.ebitex.io']} path={pathname} scope="app">
<StandaloneComponent id="site-header" value={header}>{(value) => <Header value={value} />}</StandaloneComponent>
<Experience path={pathname} notFound={<p>Not found</p>} />
</PreviewBridge>
</ContentProvider>
)
}

With scope="app", the bridge always renders its children and hands drafts down through context instead. <StandaloneComponent> registers its id with Composer, renders the draft when there is one and your fetched value otherwise, and gets field pins like any Presentation. Outside a preview it adds no element and no attribute. If your site fetches a standalone component itself, you can skip that fetch while isPreviewActivated(window.location, window) is true, because Composer sends the draft anyway.

@ebitex/content-sdk/preview has no React dependency. isPreviewActivated is the activation rule, and createPreviewReceiver speaks the protocol: it calls onDocument with each draft, and you answer with postRendered once it is on screen. The end of the example above shows the shape.

The bridge works unchanged: the server’s HTML is the first paint, and the bridge takes over once the page hydrates. One addition matters. Composer frames a page at its address before that page has ever been published, so your server resolves it to notFound and renders your 404 page. Mount <PreviewBridge> there too, inside a <ContentProvider>, with your not-found content as its children, and hide that content while isPreviewActivated(window.location, window) is true. The bridge replaces the children when the draft arrives. Keep the response a real 404: a visitor never activates the bridge, so they still see your not-found page.

The bridge only receives. It accepts drafts from the origins you list, and Composer posts only to the host mapped for the site. When an editor clicks a pin, the site tells Composer which field was clicked. The editing and saving happen in Composer, under the editor’s own sign-in. Your key stays exactly as browser-safe as it was.

A site that renders every page on its server has nothing in the browser for Composer to post a draft into, so its server reads the draft itself. That takes two credentials, and neither is enough alone:

  • the site’s server-side delivery key, with Allow draft preview turned on under API Keys. A browser-safe key can never have it;
  • a preview session that Composer creates for the editor.

Then, under Settings › Sites, set the host’s Live preview to Server.

1. Composer frames the page with a one-time token.

GET https://www.example.com/pricing?ebitex-preview-token=cpt_…

A token is valid for five minutes and can be spent once.

2. Your server exchanges it for a session.

POST https://api.ebitex.io/content/delivery/v1/preview/exchange
Authorization: Bearer <server-side key>
Content-Type: application/json
{ "token": "cpt_…" }
{ "session": "cps_…", "expiresAtUtc": "2026-09-15T12:30:00Z" }

A spent, expired or unknown token, or one created for a different delivery environment, answers 401 invalid_preview_token. A key without Allow draft preview answers 403 scope_denied. See Exchange a preview token.

3. It keeps the session in a cookie and drops the token from the address.

HTTP/1.1 302 Found
Location: /pricing
Set-Cookie: ebitex_preview=cps_…; Path=/; HttpOnly; Secure; SameSite=None; Partitioned

The cookie name is yours. Inside Composer’s frame your site is a third-party context, and Partitioned keeps the cookie in Composer’s partition, which lets it survive in browsers that block third-party cookies. Browsers treat http://localhost as secure, so Secure works locally too.

4. Every render that has the cookie sends it.

GET https://api.ebitex.io/content/delivery/v1/path/pricing?site=<rootNodeId>&resolve=10&ctx=%7B%7D
Authorization: Bearer <server-side key>
X-Ebitex-Preview-Session: cps_…

The response has the same shape as a published one. It carries X-Ebitex-Preview: draft when the path is the page being previewed and X-Ebitex-Preview: published otherwise, plus Cache-Control: private, no-store. Keep these responses out of your own caches and CDN as well.

The whole handler:

preview-session.ts
// Live preview for a site that renders every page on its server and runs nothing in the browser.
//
// Composer opens the page with a one-time `ebitex-preview-token` query parameter. This server
// exchanges the token for a preview session, keeps the session in its own cookie, and sends it as
// the `X-Ebitex-Preview-Session` header whenever it resolves a page. It is written as a fetch-style
// handler, a `Request` in and a `Response` out, which most server frameworks can host.
import {
ContentDeliveryError,
MAX_RESOLVE_DEPTH,
createDeliveryClient,
type DeliveryClient,
type PathResult,
type PresentationPathResult,
} from '@ebitex/content-sdk'
// A server-side delivery key with Allow draft preview turned on, read from your own environment.
// A browser-safe key can never read drafts.
declare const SERVER_KEY: string
declare const SITE_ID: string
declare function renderPage(page: PresentationPathResult): string
const COOKIE = 'ebitex_preview'
// Inside Composer's frame this site is a third-party context. `Partitioned` keeps the cookie in
// Composer's partition, so browsers that block third-party cookies still keep it. Browsers treat
// http://localhost as secure, so `Secure` works in local development too.
const COOKIE_ATTRIBUTES = 'Path=/; HttpOnly; Secure; SameSite=None; Partitioned'
const published = createDeliveryClient({ apiKey: SERVER_KEY })
// The same key, with the session added to every request this client makes.
function withSession(session: string): DeliveryClient {
return createDeliveryClient({
apiKey: SERVER_KEY,
fetch: (input, init) => {
const headers = new Headers(init?.headers)
headers.set('X-Ebitex-Preview-Session', session)
return fetch(input, { ...init, headers })
},
})
}
// Spends the token. `null` when it was already spent, has expired (a token lasts five minutes), or
// is unknown: the API answers 401 with `invalid_preview_token`.
async function exchangeToken(token: string): Promise<string | null> {
const response = await fetch(`${published.baseUrl}/content/delivery/v1/preview/exchange`, {
method: 'POST',
headers: { Authorization: `Bearer ${SERVER_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (!response.ok) return null
const body = (await response.json()) as { session: string; expiresAtUtc: string }
return body.session
}
// `null` when nothing is published at the path.
async function resolve(client: DeliveryClient, path: string): Promise<PathResult | null> {
try {
// Send a context bag, even an empty one. Without it, personalized fields come back unresolved.
return await client.resolvePath(path, { site: SITE_ID, resolve: MAX_RESOLVE_DEPTH, ctx: {} })
} catch (error) {
if (error instanceof ContentDeliveryError && error.code === 'path_not_found') return null
throw error
}
}
function readCookie(request: Request, name: string): string | undefined {
for (const part of (request.headers.get('Cookie') ?? '').split(';')) {
const [key, ...value] = part.trim().split('=')
if (key === name) return value.join('=')
}
return undefined
}
export async function handle(request: Request): Promise<Response> {
const url = new URL(request.url)
// Composer framed the page with a token. Exchange it, then redirect to the same address without
// the token, so it never stays in the address bar or the history.
const token = url.searchParams.get('ebitex-preview-token')
if (token !== null) {
const session = await exchangeToken(token)
url.searchParams.delete('ebitex-preview-token')
const headers = new Headers({ Location: url.pathname + url.search })
if (session !== null) headers.append('Set-Cookie', `${COOKIE}=${session}; ${COOKIE_ATTRIBUTES}`)
return new Response(null, { status: 302, headers })
}
const headers = new Headers({ 'Content-Type': 'text/html; charset=utf-8' })
const session = readCookie(request, COOKIE)
let page: PathResult | null
if (session === undefined) {
page = await resolve(published, url.pathname)
} else {
// A draft is one editor's work in progress. Keep it out of every cache, yours included.
headers.set('Cache-Control', 'private, no-store')
try {
page = await resolve(withSession(session), url.pathname)
} catch (error) {
if (!(error instanceof ContentDeliveryError) || error.code !== 'invalid_preview_session') throw error
// The editor closed live preview, or the session ended. Forget it and serve what is published.
headers.append('Set-Cookie', `${COOKIE}=; Max-Age=0; ${COOKIE_ATTRIBUTES}`)
page = await resolve(published, url.pathname)
}
}
if (page === null) return new Response('Not found', { status: 404, headers })
if (page.kind === 'redirect') {
headers.set('Location', page.targetPath)
return new Response(null, { status: 308, headers })
}
return new Response(renderPage(page), { status: 200, headers })
}
  • Only the page being previewed is a draft. Every other path answers what is published, so a page that has never been published, reached by a link, is still 404 path_not_found. The draft includes the editor’s unsaved edits.
  • The editor’s simulated context wins. The draft resolves with the audiences and properties the editor chose in Composer’s preview context panel, in place of your ctx.
  • Only GET /path reads drafts. The header on any other endpoint is 400 preview_not_supported. raw=true with a session is 400 preview_raw_unsupported, and a path in a different site from the page being previewed is 403 preview_site_mismatch.

A session ends when the editor closes live preview, after 30 minutes without an edit, 8 hours after it started, or when the editor leaves the organization. After that it answers 401 invalid_preview_session: clear the cookie and render the published page.

Composer reloads the frame each time the editor pauses typing. It marks the preview Live only once your server has actually read the latest draft. If the frame loads without reading it, Composer says The site loaded, but it did not read the draft. The usual causes are a host still set to Bridge, a key without Allow draft preview, or a browser that refused the cookie inside the frame. There are no pins and no Browse mode on a server-mode host, because nothing runs in the browser to draw them.