Skip to content

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.

  1. The edit view resolves the document’s preview URL from admin.livePreview.url and iframes it in a resizable split pane.
  2. On every form change (debounced), it posts the form’s complete current values over two transports at once: postMessage to the surface it holds a window reference for, and a shared BroadcastChannel.
  3. On the public page, LivePreviewProvider keeps an id → unsaved values map, and useLivePreview overlays the entry matching the document your own query already fetched.
  4. 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.

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.

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" }],
},
},
})

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.

components/providers/client.tsx
"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",
)

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:

  1. collection.admin.livePreview.<field> / global.admin.livePreview.<field>
  2. admin.livePreview.collections[slug].<field> / admin.livePreview.globals[slug].<field>
  3. admin.livePreview.<field> (root breakpoints)
  4. the built-in default

Per field, not per block: a collection overriding only defaultOpen still inherits the root entry’s url.

url takes three forms:

url: "/" // literal path
url: (doc) => `/${doc.slug}` // resolved in the browser
url: { server: async ({ doc, ctx }) => … } // resolved on Convex, with ctx

The { 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.db in a browser.
  • doc is 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 NextAdminPage resolves the first URL server-side, so opening an editor never waits on Convex.
  • Wire the endpoint once: add livePreviewUrl to your convex/vex.ts collectionsApi destructure. It runs the collection’s own read permission check — without it, a resolver reading ctx.db would be an open oracle.

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 whatever admin.livePreview.url returns, so your resolver returns a plain public path and never has to know about it, and
  • the vex-live-preview cookie (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=1 should render the normal published page, not redirect to sign-in.
  • List your admin origins in livePreview.allowedOrigins. An empty list disables the cross-origin postMessage transport 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.
  • Same browser only. postMessage and BroadcastChannel are 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.
  • postMessage needs a window reference, which the admin only has for surfaces it opened itself. BroadcastChannel covers 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.