Live Preview
Live preview renders the document’s real public route beside the edit form and updates it as you type — no separate preview route, no draft save, no autosave. The page being previewed is the same page a visitor gets; only the data is overlaid.
How it works
Section titled “How it works”- The edit view resolves the document’s preview URL from
admin.livePreview.urland iframes it in a resizable split pane. - On every form change (debounced), it posts the form’s complete current values
over two transports at once:
postMessageto the surface it holds a window reference for, and a sharedBroadcastChannel. - On the public page,
LivePreviewProviderkeeps anid → unsaved valuesmap, anduseLivePreviewoverlays the entry matching the document your own query already fetched. - A newly-opened preview surface sends a handshake on mount; the admin panel replies with the current values immediately, so a duplicated tab catches up without waiting for the next keystroke.
On a narrow admin viewport — below the 768px mobile breakpoint, distinct from the
simulated-width breakpoints toggle described below, which only applies to the
desktop split — the split panel doesn’t fit beside the form. Narrowing the browser
past that breakpoint while the panel is open auto-closes it instead of rendering
as a full-screen takeover, and remembers the close was automatic; widening back past
the breakpoint reopens it, unless you explicitly opened or closed it while narrow
(which clears that memory and the panel stays however you left it). Opening the panel
below the breakpoint — the header button or the ⋯ menu’s “Preview” item — instead
shows it as a bottom sheet that slides up and covers the screen except a thin strip of
the page at the top; Esc, a tap outside, or its own close button all dismiss it. None
of these width-driven transitions touch the saved panel-open preference —
only an explicit toggle does, so a refresh at desktop width always respects your last
deliberate choice.
Both panel preferences — open/closed and the handle position — persist in a
consolidated, basePath-scoped admin cookie (vex-admin/vex-admin:<collection>)
rather than localStorage, scoped per collection or global. The admin page reads them
on the server, so a refresh paints the split at the width you left it at instead of
snapping to it after hydration. See Admin cookies for the
full cookie layout.
Lifecycle hooks never run on preview data — you see exactly what you typed, before
beforeChange would transform it.
Configure the collection
Section titled “Configure the collection”import { defineCollection, text } from "@vexcms/core"import { resolvePagePath } from "~/lib/resolvePagePath"
export const pages = defineCollection({ slug: "pages", admin: { useAsTitle: "title", livePreview: { // A resolver when the URL depends on the document; `doc` is // Partial<PagesDocument>, so doc.slug is typed, not unknown. url: (doc) => resolvePagePath(doc.slug), // Optional: debounceMs: 150, // sync cadence for this collection defaultOpen: false, // panel state before the editor ever chooses // breakpoints: [...] // overrides the root list, rarely needed }, }, fields: { title: text({ required: true }), slug: text({ required: true }) },})url also takes a literal path for a collection or global that always previews in one
place — a site header or footer rendered on every page is url: "/", not
url: () => "/".
admin.livePreview.url is independent of routes.map. routes.map answers “every
public path this document renders at” (it drives revalidation and sitemaps);
livePreview.url answers “the one URL to iframe”, and may point at a different origin
such as staging. Share a helper between them if they agree, as above.
Globals take the identical admin.livePreview block.
Configure the project
Section titled “Configure the project”export default defineConfig({ collections: [pages], admin: { livePreview: { // Origins allowed to post preview frames to a listener. Empty by default, // which means the postMessage transport accepts nothing. allowedOrigins: ["http://localhost:3000", "https://admin.example.com"], // Optional — defaults to 375 / 390 / sm / md / lg / xl / 2xl. // breakpoints: [{ label: "sm", width: 640, icon: "Smartphone" }], }, },})Wire the public site
Section titled “Wire the public site”LivePreviewProvider reads everything it needs — collections, globals, and
allowedOrigins — from VexConfigContext, so it takes no props. Mount
VexConfigProvider once at the app root, from a client component that imports your
config directly: the config carries functions (admin.livePreview.url, field
validators), so it cannot cross the server/client boundary as a prop.
"use client"
import { VexConfigProvider } from "@vexcms/react"import config from "~/vex.config"
export default function ClientProviders({ children }: { children: React.ReactNode }) { return <VexConfigProvider config={config}>{children}</VexConfigProvider>}The root mount covers the admin panel and the public site from one provenance. Then wrap
the tree once, in app/layout.tsx:
<ClientProviders> <LivePreviewProvider>{children}</LivePreviewProvider></ClientProviders>The provider gates itself (see below), so a normal visitor pays for a disabled provider
and nothing else — and the route stays statically prerenderable, because nothing here
reads headers() or cookies() on the server.
Then overlay your own query result:
"use client"
import { convexQuery } from "@convex-dev/react-query"import { RenderBlocks, useLivePreviewQuery } from "@vexcms/react"
export function PageContent({ slug, initialData }) { const { data: page } = useLivePreviewQuery( { ...convexQuery(api.pages.getBySlug, { slug }), initialData }, "pages", ) if (!page) return null return <RenderBlocks blocks={page.blocks} components={blockComponents} />}useLivePreviewQuery is sugar for the common getBySlug-returns-an-array shape: it
narrows to the first document for you. A query that already resolves to a SINGLE
document — a getFirst-style singleton read, or a lookup by id — uses
useLivePreviewDocumentQuery with the identical signature.
Every editable document a page renders needs one of these, not just the page’s own: the
overlay only reaches documents a consumer hands it, so a site header fetched with a bare
useQuery stays frozen at its saved values while the header is being edited.
const { data: header } = useLivePreviewDocumentQuery( { ...convexQuery(api.headers.getFirst, {}), initialData }, "headers",)Declaring previews in one place
Section titled “Declaring previews in one place”Every collection’s and global’s preview settings can live in the root config
instead of on each defineCollection:
defineConfig({ admin: { livePreview: { allowedOrigins: ["http://localhost:3000"], collections: { headers: { url: "/" }, pages: { url: (doc) => resolvePagePath(doc.slug), debounceMs: 250 }, }, globals: { siteSettings: { url: "/", defaultOpen: true }, }, }, },})Whole settings blocks, not just URLs — the key supplies the slug, so each
entry’s url resolver is typed against that collection’s generated document
with no annotation.
A collection’s own admin.livePreview wins, field by field. Order per
setting, highest first:
collection.admin.livePreview.<field>/global.admin.livePreview.<field>admin.livePreview.collections[slug].<field>/admin.livePreview.globals[slug].<field>admin.livePreview.<field>(rootbreakpoints)- the built-in default
Per field, not per block: a collection overriding only defaultOpen still
inherits the root entry’s url.
Resolving a URL on the server
Section titled “Resolving a URL on the server”url takes three forms:
url: "/" // literal pathurl: (doc) => `/${doc.slug}` // resolved in the browserurl: { server: async ({ doc, ctx }) => … } // resolved on Convex, with ctxThe { server } form runs on Convex with a real query context, so it can look
the database up — e.g. find the page that references the document being edited:
ctx is typed with no generic and no wrapper: vex generate emits your project’s
DataModel into the @vexcms/core module augmentation, alongside CollectionSlug and
the document maps, so ctx.db.query(...) is checked against your real tables wherever a
resolver is declared. Before the first vex generate it falls back to Convex’s
GenericDataModel rather than erroring.
In the root map — doc comes from the key:
export default defineConfig({ admin: { livePreview: { collections: { authors: { url: { server: async ({ ctx, doc }) => { const page = await ctx.db .query("pages") .withIndex("by_author", (q) => q.eq("author", doc._id)) .first() return page ? `/${page.slug}` : undefined }, }, }, }, }, },})The resolver also receives vex, a read-only VexCMS API (find/get/search/
globals.get), which returns flat globals and populated relationships and shares the
access resolver — so “find the page that references this document” is one call rather
than a hand-written join:
url: { server: async ({ vex }) => { const [page] = await vex.find({ collection: "pages", limit: 1 }) return page ? `/${page.slug}` : "/"} }Or beside the collection — doc comes from the sibling slug:
defineCollection({ slug: "authors", admin: { livePreview: { url: { server: async ({ ctx, doc }) => { const page = await ctx.db .query("pages") .withIndex("by_author", (q) => q.eq("author", doc._id)) .first() return page ? `/${page.slug}` : undefined }, }, }, },})livePreviewUrl(slug, fn) / globalLivePreviewUrl(slug, fn) are still exported for
callers that need to type a resolver away from its config — the slug is the only type
argument, since ctx is already the project’s own context — but no config file needs
them.
Notes that matter:
- It stays in the client config, beside the collection, exactly like a
field’s
validate()callback. It runs only on Convex, but the closure is bundled into the browser — so do not capture secrets in it. - It is an object, not a bare async function. At runtime a function
returning a promise is indistinguishable from the sync client resolver, and
guessing wrong would mean calling
ctx.dbin a browser. docis the saved document merged with the editor’s unsaved values, which is why this runs per change rather than once.- Only this form costs a round trip. Strings and client resolvers resolve
synchronously as before, and
NextAdminPageresolves the first URL server-side, so opening an editor never waits on Convex. - Wire the endpoint once: add
livePreviewUrlto yourconvex/vex.tscollectionsApidestructure. It runs the collection’s own read permission check — without it, a resolver readingctx.dbwould be an open oracle.
Security: the gate is yours to set
Section titled “Security: the gate is yours to set”The listener attaches only when both are true on the previewed page:
- the URL carries
?vexLivePreview=1(LIVE_PREVIEW_QUERY_PARAM) — the admin panel appends this to whateveradmin.livePreview.urlreturns, so your resolver returns a plain public path and never has to know about it, and - the
vex-live-previewcookie (LIVE_PREVIEW_COOKIE) holds"1".
Your middleware sets that cookie, and only after verifying an admin session — session verification calls your own generated Convex API, so the framework cannot do it for you:
import { LIVE_PREVIEW_COOKIE, LIVE_PREVIEW_COOKIE_MAX_AGE_SECONDS, LIVE_PREVIEW_QUERY_PARAM,} from "@vexcms/core"
async function grantPreviewIfRequested(request: NextRequest) { if (request.nextUrl.searchParams.get(LIVE_PREVIEW_QUERY_PARAM) !== "1") { return NextResponse.next() } if ((await resolveSessionStatus()) !== "authenticated") { return NextResponse.next() }
const response = NextResponse.next() response.cookies.set(LIVE_PREVIEW_COOKIE, "1", { path: "/", maxAge: LIVE_PREVIEW_COOKIE_MAX_AGE_SECONDS, sameSite: "lax", httpOnly: false, // read by the provider in the browser; carries no secret }) return response}Checklist for any project embedding a preview surface:
- Fail open on the public route. An absent or stale session with
?vexLivePreview=1should render the normal published page, not redirect to sign-in. - List your admin origins in
livePreview.allowedOrigins. An empty list disables the cross-originpostMessagetransport entirely. - Never widen the allowlist to
"*". Every incoming payload is still validated against the target collection’s Zod schema, but same-origin is not the same guarantee as trustworthy.
Limits
Section titled “Limits”- Same browser only.
postMessageandBroadcastChannelare both same-machine, same-browser-process constructs. A preview opened on your phone while you type on your laptop will not update — that needs a persisted network channel, which is out of scope. postMessageneeds a window reference, which the admin only has for surfaces it opened itself.BroadcastChannelcovers the rest (duplicated tab, reopened from history, second monitor, pasted URL) as long as it is the same origin.- The preview is pre-
beforeChange. Fields derived by hooks at write time will not appear until you save. - The panel enables client-side, after hydration — the previewed page renders no extra chrome of its own, so nothing shifts when it does.