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.
Previewing a presentation on its own
Section titled “Previewing a presentation on its own”An editor can preview a presentation that no page reaches (a shared Component, a Template under construction) without any change to your site. It builds a page around the presentation and posts it to the bridge exactly as it posts a page’s own draft, so a site that previews pages already previews these. Parts of that page that the author did not write carry "synthetic": true on their preview annotation; the bridge draws them as dashed boundaries, and your own code can ignore the member. See the resolvePreview reference for the request that produces it.
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.
Pins on a server-rendered site
Section titled “Pins on a server-rendered site”Everything above works with no client-side code at all — and without it there are no pins, so an editor edits through Composer’s own panel. Three additions give a server-rendered page the same numbered pins and click-to-edit a browser-rendered one has. No package to install and nothing to build.
1. Load the script. It is served by Composer, at a fixed address:
<script src="https://content.ebitex.io/preview.js"></script>Include it on every page. It does nothing unless the page is genuinely being previewed — it requires
both the ebitex-preview=1 flag Composer frames you with and a framing window — so a visitor pays
one cached request and nothing else happens.
2. Ask for the annotation on your draft read. Add annotate=true to the /path request you
already make with the session:
GET /content/delivery/v1/path/about?site=<id>&annotate=trueX-Ebitex-Preview-Session: cps_…A read answered as a draft comes back with a preview object on the Presentation envelope, naming
the entity and the fields behind each part of the page. A read answered as published ignores the
parameter — it is not an error — so you can send it on every preview request and let the answer
decide.
3. Emit what you were given. The script needs two attributes and one JSON document:
| What | Where | Carries |
|---|---|---|
data-ebitex-source="<id>" |
An element wrapping one Presentation’s output. Use style="display:contents" so it has no box of its own. |
An id you choose, unique within the page |
data-ebitex-field="<key>" |
The element rendering one field | The field’s key, as listed under the annotation’s fields |
<script type="application/json" data-ebitex-preview-sources> |
Once, at the end of the body | {"<id>": {"source": <the preview object>, "envelope": {"template": {…}}}} |
The registry’s source is the preview object exactly as the API sent it — copy it, don’t translate
it. Emit the registry after the body, since the ids do not exist until the page has rendered.
Mark the boundary in whatever single place your renderer dispatches a Presentation, and every nested Presentation and RichText embed is marked by the same rule. Field marks are optional and per-field: without them you get one pin per Presentation, with them a click lands on the exact field.
A worked implementation is in northwind-coffee-mvc — about
80 lines of C# and one line in each partial.
What the editor gets
Section titled “What the editor gets”Pins numbered in document order, a click opening that field in Composer, and Composer following you when you navigate: the script reports the path it landed on, and Composer starts previewing that page. A site that does not load the script keeps the panel-only experience, unchanged.