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.ioWithout it, the browser blocks the frame and Composer reports that the site didn’t answer.
Bridge mode
Section titled “Bridge mode”Mount the bridge
Section titled “Mount the bridge”Wrap the part of your app that renders a page in <PreviewBridge>, inside <ContentProvider>:
// 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: stringdeclare const pathname: stringdeclare 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: exactscheme://host[:port]values,https://content.ebitex.ioby default; - renders each draft through
<Experience result>in place of its children, using your renderers; - reports
pathas 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.
Pin the fields you render
Section titled “Pin the fields you render”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.
Preview content outside the page
Section titled “Preview content outside the page”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>:
// 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: stringdeclare const pathname: stringdeclare 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.
A site that isn’t built with React
Section titled “A site that isn’t built with React”@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.
A server-rendered app that hydrates
Section titled “A server-rendered app that hydrates”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.
What the site can do
Section titled “What the site can do”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.
Server mode
Section titled “Server mode”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.
The flow
Section titled “The flow”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/exchangeAuthorization: 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 FoundLocation: /pricingSet-Cookie: ebitex_preview=cps_…; Path=/; HttpOnly; Secure; SameSite=None; PartitionedThe 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%7DAuthorization: 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:
// 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: stringdeclare const SITE_ID: stringdeclare 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 })}What a session answers
Section titled “What a session answers”- 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 /pathreads drafts. The header on any other endpoint is400 preview_not_supported.raw=truewith a session is400 preview_raw_unsupported, and a path in a different site from the page being previewed is403 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.
What the editor sees
Section titled “What the editor sees”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.