Live Preview
See content changes reflected in your frontend in real time while editing in the admin.
Live preview lets editors see their changes reflected on the actual frontend page as they type — no save required. The admin renders your site in an iframe panel alongside the form, and pushes form data to the iframe via postMessage on every change.
How it works
- The admin opens your frontend URL in an embedded iframe.
- As the editor changes fields, the admin posts the current form data to the iframe via
window.postMessage. - Your frontend uses the
useLivePreviewhook to receive those updates and re-render with the new data. - A preview API key is passed as a
?token=query param so the frontend can fetch draft content.
Setup
1. Create a preview role
Add a role with read access for the entities you want to preview. This role will be used to generate a preview API key that bypasses public-facing filters (e.g. status: published).
If you're public role is already configured with read access across all entities, you can skip this step.
// cms.config.ts
import { config, role } from "@cms/config";
const preview = role("preview", {
permissions: {
page: { read: true },
},
});
export default config({
roles: [admin, publicRole, preview],
// ...
});
2. Add adminLivePreview to your entity
Import adminLivePreview from @cms/live-preview and call it inside admin.preview. The preview callback receives the current form data — fully typed to your entity's fields — and you pass data and a url string to adminLivePreview.
// cms.config.ts
import { entity, slug, text } from "@cms/config";
import { adminLivePreview } from "@cms/live-preview";
const page = entity("page", {
admin: {
preview: ({ data }) =>
adminLivePreview({
data,
url: `http://localhost:3001/${data.slug}?token=${import.meta.env.VITE_CMS_PREVIEW_API_KEY}`,
}),
},
fields: [slug("slug"), text("title")],
});
The preview callback is called on every form change. The iframe reloads only on mount — after that, updates arrive via postMessage without a full page reload.
The
VITE_CMS_PREVIEW_API_KEYenvironment variable should hold an API key generated for thepreviewrole. Set it inexamples/demo/.env(or your consumer project's.env).
3. Use useLivePreview in your frontend
In your consumer site, import useLivePreview from @cms/live-preview/react. Pass the server-fetched data as the initial value — the hook returns it unchanged until the admin starts sending updates.
// your-frontend/src/routes/$.tsx
import { useLivePreview } from "@cms/live-preview/react";
function PageRoute() {
const { page: initialPage } = Route.useLoaderData();
const page = useLivePreview(initialPage);
return <div>{page.title}</div>;
}
The hook listens for postMessage events with type: "cms-live-preview" and updates state with the latest data. On first load (no live session), it simply returns initial.
4. Allow the preview token in your frontend loader
When the iframe URL includes a ?token= query param, your frontend should use it as the API key so it can fetch draft/unpublished content:
loader: async ({ params, location }) => {
const previewToken =
"token" in location.search && (location.search.token as string);
if (previewToken) {
api.config({ apiKey: previewToken });
}
const { data: [page] } = await api.entities.list("page", {
filter: { slug: { $eq: `/${params._splat}` } },
limit: 1,
});
if (!page) throw notFound();
return { page };
},
Admin UI
When an entity has admin.preview set, a Preview button appears in the document toolbar. Clicking it opens a split-pane view with the form on the left and the iframe on the right.
The iframe toolbar provides:
- Refresh — reloads the iframe
- Open in new tab — opens the preview URL in a new browser tab
- Pop to window — opens a standalone preview window that continues to receive live updates via
postMessage
API reference
adminLivePreview(options) — @cms/live-preview
Renders an iframe preview panel. Call it from inside the admin.preview callback.
type AdminLivePreviewOptions = {
/** The current form data — pass `data` from the preview callback. */
data: unknown;
/** The URL to load in the iframe. */
url: string;
};
useLivePreview<T>(initial: T): T — @cms/live-preview/react
React hook for consumer sites. Subscribes to postMessage events from the admin and returns the latest data.
import { useLivePreview } from "@cms/live-preview/react";
const live = useLivePreview(serverData);
// live === serverData until the admin connects, then updates in real time
EntityAdmin.preview — @cms/types
The preview option on an entity's admin config accepts any React component:
type EntityPreviewProps = {
data: Record<string, unknown>; // current form data (unsaved)
};
type EntityAdmin = {
preview?: React.ComponentType<EntityPreviewProps>;
};
You can supply a fully custom preview component instead of adminLivePreview if you need something other than an iframe (e.g. a JSON inspector, a diff view, or a custom renderer).
Verifying it works
There are no automated tests for the postMessage transport — the meaningful logic is a few lines and the rest is React framework glue that's not worth mocking. To verify live preview works end-to-end:
- Run
examples/marketing-siteagainst its bundled CMS engine. - Open the admin, edit any page in the document form.
- Watch the iframe panel update on every keystroke without saving.
If the postMessage protocol ever changes (the LIVE_PREVIEW_MESSAGE constant or the {type, data} envelope shape), this manual smoke test catches it immediately. TypeScript catches incompatible signature changes at consumer compile-time.
Previous
Admin Extensibility
Next
Deployment