⚠️ Consider using the successorpayload-better-editor. It offers the same as this plugin, but also adds a side-by-side overlay with a sidebar of native Payload fields, viewport switching (Desktop / Tablet / Mobile / Responsive / Fullscreen), undo/redo, in-sidebar block actions (move, duplicate, add-below, delete), drag-resizable sidebar, an interact mode for clicks inside the preview, and aBetterEditorSettingsglobal so admins can tune sidebar position, hover colours, and breakpoints live without a re-deploy.
Better live preview for Payload CMS — hover highlighting with block identification, bi-directional admin/preview sync, and smooth transitions.
Found a bug? Please open an issue with steps to reproduce, your Payload version, and a minimal example. PRs welcome too.
Blue overlay marks the block under the cursor with a label badge showing block type, index, and name. Nested blocks get a dashed parent overlay with breadcrumb labels.
Click a block row in the admin editor — the preview scrolls to that block and highlights it with a flash effect.
Click a block in the preview — the admin editor scrolls to the corresponding block row, expands it if collapsed (including all ancestor rows for nested blocks), and highlights it.
- Draft-only — Zero impact on published pages
- Scroll/Resize tracking — Overlay follows block position smoothly
- Infinite nesting — Bi-directional sync works at any block nesting depth
pnpm add payload-better-preview
# or
npm install payload-better-preview// payload.config.ts
import { betterPreview } from 'payload-better-preview'
export default buildConfig({
plugins: [
betterPreview({
accentColor: '#3b82f6', // optional
scrollAlign: 'start', // optional
scrollOffset: 128, // optional
}),
],
admin: {
livePreview: {
collections: ['pages'],
url({ data }) {
return `/${data.id}`
},
},
},
})Each rendered block must have three data attributes:
| Attribute | Description |
|---|---|
data-block |
Block type slug, e.g. "hero", "text" |
data-block-index |
0-based index within the blocks field |
data-block-field |
Field path — see below |
data-block-name |
Optional display name shown in the overlay label |
The data-block-field value must match the Payload field name so the plugin can map blocks between the admin and the preview:
export function BlockRenderer({ block, index, field }) {
return (
<div
data-block={block.blockType}
data-block-index={index}
data-block-field={field}
data-block-name={block.name} // optional
>
{/* block content */}
</div>
)
}
export function RenderBlocks({ blocks, field }) {
return blocks.map((block, index) => (
<BlockRenderer key={block.id} block={block} index={index} field={field} />
))
}// pass the exact Payload field name
<RenderBlocks blocks={page.content} field="content" />
<RenderBlocks blocks={page.sidebar} field="sidebar" />import { BetterPreview } from 'payload-better-preview/client'
export default async function Page() {
const { isEnabled: draft } = await draftMode()
return (
<>
{draft && <LivePreviewListener />}
{draft && <BetterPreview />}
{/* ... rest of page */}
</>
)
}For blocks that contain other blocks, construct the field prop by concatenating the parent field, parent index, and child field name with hyphens. This matches the ID pattern Payload generates in the admin and enables sync at any depth:
export function NestedBlockRenderer({ block, index, field }) {
return (
<div
data-block={block.blockType}
data-block-index={index}
data-block-field={field}
>
<RenderBlocks
blocks={block.content}
field={`${field}-${index}-content`}
{/* e.g. "content-0-content", "content-0-content-1-sidebar" */}
/>
</div>
)
}Payload's Lexical editor supports blocks embedded directly inside richText fields. The plugin provides full bi-directional sync for these blocks too.
Requires Payload 3.81+. On older versions the Lexical plugin component is rendered outside of the
LexicalComposerContextduring SSR and throws. The core block sync (layout blocks, hover highlighting, Admin ↔ Preview sync) still works on Payload ^3.0.0 — only the richText-block sync is gated on 3.81+.
Add BetterPreviewLexicalFeature to your Lexical editor config. This injects a client-side plugin that stamps each block in the editor with a data-block-id attribute so the admin can locate them:
// payload.config.ts
import { betterPreview, BetterPreviewLexicalFeature } from 'payload-better-preview'
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical'
export default buildConfig({
plugins: [betterPreview()],
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({ blocks: [...] }),
BetterPreviewLexicalFeature(),
],
}),
})Add data-block-id (the block's unique ID from node.fields.id) to each block wrapper. The plugin uses this ID to sync between admin and preview:
// jsxConverters.tsx
import { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react'
export const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
hero: ({ node }) => (
<div
data-block={node.fields.blockType}
data-block-id={node.fields.id} // required for richText sync
>
{/* block content */}
</div>
),
},
})data-block-ididentifies each block uniquely across both admin and preview- Admin → Preview: clicking a Lexical block in the admin sends
scroll-to-richtext-blockwith the block ID — the preview finds[data-block-id]and scrolls to it - Preview → Admin: clicking a block with
data-block-id(but nodata-block-field) sendsfocus-richtext-block— the admin finds[data-block-id], expands the collapsible if needed, and scrolls to it
Add data-preview-ignore to any element to prevent clicks on it from triggering scroll sync. Works in both directions — in the preview (blocks rendered on the frontend) and in the admin (custom block components inside the Lexical editor).
Useful for interactive elements like buttons, links, or custom controls inside a block component:
// jsxConverters.tsx — preview side
blocks: {
cta: ({ node }) => (
<div
data-block={node.fields.blockType}
data-block-id={node.fields.id}
>
<h2>{node.fields.title}</h2>
{/* clicks here will NOT trigger scroll sync */}
<button data-preview-ignore onClick={...}>
Buy now
</button>
</div>
),
},// Custom block component — admin side (Lexical editor)
export function CtaBlockComponent({ formData }) {
return (
<div>
<p>{formData.title}</p>
{/* clicks here will NOT send scroll-to-preview message */}
<button data-preview-ignore onClick={openModal}>
Edit link
</button>
</div>
)
}Any click originating inside a [data-preview-ignore] element is ignored by the plugin entirely — hover highlighting and scroll sync are both skipped for that interaction.
For blocks that are nested inside a richText block (e.g. a nestedBlock with its own blocks field), sync works the same as regular nested blocks. The plugin automatically detects the parent richText block and scopes the search to avoid duplicate IDs.
Add data-block-field and data-block-index to nested blocks (but NOT to direct richText blocks since they have no field path):
// For the outer richText block (nestedBlock):
<div
data-block={node.fields.blockType}
data-block-id={node.fields.id}
>
<RenderBlocks
blocks={node.fields.content}
field="content" // local field name only (not full path)
richTextBlockId={node.fields.id}
/>
</div>
// For inner blocks (BlockRenderer receives richTextBlockId from parent):
<div
data-block={block.blockType}
data-block-id={block.id}
data-block-index={index}
data-block-field={field} // e.g. "content"
>| Option | Type | Default | Description |
|---|---|---|---|
disabled |
boolean |
false |
Disable the plugin entirely |
accentColor |
string |
'#3b82f6' |
Highlight color for overlays and flash effects in the admin |
scrollAlign |
'start' | 'center' | 'end' |
'start' |
Block alignment when scrolling in admin |
scrollOffset |
number |
128 |
Top offset in px when scrolling in admin — accounts for the sticky admin header |
| Prop | Type | Default | Description |
|---|---|---|---|
accentColor |
string |
'#3b82f6' |
Highlight color for overlays and flash effects in the preview |
scrollAlign |
'start' | 'center' | 'end' |
'start' |
Block alignment when scrolling in the preview |
scrollOffset |
number |
0 |
Top offset in px when scrolling in the preview — useful for fixed headers |
showToggle |
boolean |
true |
Show the built-in toggle button |
toggleComponent |
React.ComponentType<ToggleProps> |
— | Replace the built-in toggle with a custom component |
The preview uses window.scrollTo internally to avoid propagating scroll to the parent admin frame. Because of this, scroll-margin-top has no effect. Use scrollOffset instead:
<BetterPreview scrollOffset={80} />import type { ToggleProps } from 'payload-better-preview/client'
function MyToggle({ enabled, onToggle }: ToggleProps) {
return (
<button onClick={onToggle} style={{ position: 'fixed', top: 16, right: 16 }}>
{enabled ? 'Sync on' : 'Sync off'}
</button>
)
}
<BetterPreview toggleComponent={MyToggle} />Block fields (standard):
- Admin → Preview: clicking a block row in the admin sends
scroll-to-blockwith{ field, index }to the preview iframe. The preview finds[data-block-field][data-block-index]and scrolls to it. - Preview → Admin: clicking a block with
data-block-fieldsendsfocus-block. The admin expands all ancestor rows and scrolls to the target row.
RichText (Lexical) blocks:
- Admin → Preview: clicking a Lexical block sends
scroll-to-richtext-blockwith{ blockId }. The preview finds[data-block-id]and scrolls to it. - Preview → Admin: clicking a block with
data-block-id(but nodata-block-field) sendsfocus-richtext-block. The admin finds[data-block-id], expands the collapsible if needed, and scrolls to it. - Nested blocks inside richText: clicking sends
focus-blockwith an extrarichTextBlockId. The admin opens the parent Lexical block, then scopes the row search within it — avoiding duplicate ID issues when multiple identical blocks exist.
All communication is via window.postMessage. No network requests, no shared state.
<BetterPreview /> is a 'use client' component that renders null (no React DOM output). It injects 3 absolutely-positioned DOM elements into document.body:
- Overlay — Primary block highlight (solid blue border)
- Parent Overlay — For nested blocks (dashed border, subtle)
- Label — Info badge with block type and index
All interaction is handled via event delegation on document, so it survives DOM updates from live preview re-renders.


