@focus-reactive/payload-plugin-visual-editing
v1.1.0
Published
Visual editing for Payload CMS — click-to-edit overlays on your frontend via Vercel stega and a postMessage bridge back to the admin.
Readme
@focus-reactive/payload-plugin-visual-editing
Visual editing plugin for Payload CMS. Embeds field-path markers into draft content via Vercel stega, then renders in-place edit overlays on the frontend that deep-link back into Payload admin via a postMessage bridge.
Installation
bun add @focus-reactive/payload-plugin-visual-editingPeer dependencies: payload ^3.79, @payloadcms/ui ^3.79, react ^18 || ^19. next is optional.
Compatibility
Requires Payload 3.79 or newer (within the 3.x line), supplied by the host app. @payloadcms/ui, payload, and react are peer dependencies, so the plugin shares the host's single copy rather than bundling its own. Built and tested against Payload 3.84.1.
Setup
The plugin has two halves: a server half (Payload hooks that embed stega path-markers into draft reads, register the admin bridge, and strip stega back out on write) and a client half (the frontend overlay that turns those markers into click-to-edit badges). Enrichment only happens for draft content served to the frontend — never the admin form or published reads — so the flow below wires Next.js draft mode end to end.
Steps 1–2 set up the server, steps 3–5 connect draft preview, steps 6–8 finish the client. The apps/dev app in this repo is a complete working reference.
1. Register the plugin — payload.config.ts
import { buildConfig } from 'payload'
import { visualEditingPlugin } from '@focus-reactive/payload-plugin-visual-editing'
export default buildConfig({
// ...
plugins: [
visualEditingPlugin({
// all optional:
skipCollections: ['media'], // collection slugs to exclude from enrichment
skipGlobals: [], // global slugs to exclude
excludeFieldNames: ['tenant'], // extra field names to strip (merged with '_status', 'folder', 'slug')
adminBasePath: '/admin', // Payload admin base path (default '/admin')
enrichment: 'explicit', // which reads get stega (default 'auto') — see "Enrichment modes"
}),
],
})This adds enrichment hooks to every non-skipped collection and global, registers the VisualEditingBridgeProvider admin component, and adds a beforeChange hook that strips intact stega markers from writes.
2. Enable drafts on editable collections
The plugin only enriches draft reads, so each collection you want to edit visually needs Payload drafts turned on:
export const Pages: CollectionConfig = {
slug: 'pages',
versions: { drafts: true },
// fields...
}3. Add draft-mode preview routes
Next.js draft mode is what flips a frontend read into a draft read. Add a route that enables it (and one that exits):
// app/(frontend)/next/preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(req: Request) {
const path = new URL(req.url).searchParams.get('path')
// same-origin relative paths only — block open redirects
if (!path || !path.startsWith('/') || path.startsWith('//')) {
return new Response('Invalid path', { status: 400 })
}
;(await draftMode()).enable()
redirect(path)
}// app/(frontend)/next/exit-preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(req: Request) {
const path = new URL(req.url).searchParams.get('path')
;(await draftMode()).disable()
redirect(path && path.startsWith('/') && !path.startsWith('//') ? path : '/')
}4. Point Live Preview at the preview route
So the CMS side-by-side preview opens the frontend already in draft mode, set the collection's admin.livePreview.url to go through /next/preview:
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
livePreview: {
url: ({ data }) => {
const slug = typeof data?.slug === 'string' ? data.slug : ''
return `${process.env.NEXT_PUBLIC_SERVER_URL}/next/preview?path=${encodeURIComponent(`/${slug}`)}`
},
},
},
versions: { drafts: true },
// fields...
}5. Fetch drafts in the frontend page
Read the document with draft tied to draft mode — stega is only embedded on draft reads:
// app/(frontend)/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const { isEnabled: draft } = await draftMode()
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'pages',
draft,
depth: 1, // ≥ 1 so uploads/relationships populate (see "Editable richText and upload fields")
where: { slug: { equals: slug } },
})
// render docs[0]
}With enrichment: 'explicit', also pass context: { visualEditing: draft } — see Enrichment modes.
6. Wrap the frontend layout
Mount the provider (gated on draft mode), the toggle, and the overlay:
// app/(frontend)/layout.tsx
import { VisualEditing } from '@focus-reactive/payload-plugin-visual-editing/client'
import { draftMode } from 'next/headers'
export default async function Layout({ children }: { children: React.ReactNode }) {
const { isEnabled } = await draftMode()
return (
<html>
<body>
<VisualEditing.Provider available={isEnabled} framedOnly adminBasePath="/admin">
<VisualEditing.Toggle />
<VisualEditing.Overlay>{children}</VisualEditing.Overlay>
</VisualEditing.Provider>
</body>
</html>
)
}availableenables the overlay only when draft mode is on; a user-level toggle (stored inlocalStorage) then gates whether badges actually render.framedOnly(optional) restricts the overlay to the CMS preview iframe — omit it to also allow editing when the frontend is opened in a standalone tab.VisualEditing.Togglerenders the floating off/hover/always control;VisualEditing.Overlayscans the DOM for markers and draws the edit badges.
7. Mark richText and upload fields
text / textarea / email fields are picked up automatically. richText and upload values opt out of inline stega and need one line in your renderer — see Editable richText and upload fields.
8. Regenerate the admin import map
The bridge is registered as an admin component, so refresh Payload's import map (re-run after config changes):
bunx payload generate:importmapUsing it
Open a document in the admin, open the Live Preview side panel (it loads the frontend in draft mode), then flip the floating toggle to Always or Hover. Editable text shows an outline and an Edit badge; clicking it focuses that field in the admin form. To leave draft mode when viewing the frontend directly, hit /next/exit-preview.
Editable richText and upload fields
Most fields (text, textarea, email, …) are edit-overlayable automatically — the plugin weaves zero-width stega characters into their rendered text and the client overlay picks them up. Two kinds of values opt out of stega and need one line in your renderer:
richText— treated as a leaf-renderer black box; no stega is embedded into its rendered paragraphs.upload:<slug>— its populated value is a different document whose_metais preserved for wrapper-attr consumption instead of being re-encoded.
For both, spread withVisualEditingPath(value) onto the element you want to receive the Edit badge.
import { withVisualEditingPath } from '@focus-reactive/payload-plugin-visual-editing/client'RichText
import { RichText } from '@payloadcms/richtext-lexical/react'
<div {...withVisualEditingPath(page.content)}>
<RichText data={page.content} />
</div>Upload — single relationTo
<img
src={page.image.url}
alt={page.image.alt}
{...withVisualEditingPath(page.image)}
/>Upload — polymorphic (relationTo: ['media', 'videos'])
Spread on value, not the wrapper:
<img
src={page.image.value.url}
alt={page.image.value.alt}
{...withVisualEditingPath(page.image.value)}
/>Clicking the Edit badge on an upload opens the media document's admin page (not a field on the host doc).
Uploads require depth ≥ 1 so Payload returns the populated media document alongside its _meta. At depth: 0 the value is a bare id string and no overlay is drawn.
Enrichment modes
The enrichment option decides which reads get stega.
'auto'(default) — every Local API read withdraft: trueoutside the admin base path is enriched. The plugin can't tell a preview render from any other server code that reads drafts, so plugins, jobs, hooks and scripts receive stega too.'explicit'— only reads that passcontext: { visualEditing: true }are enriched. Everything else gets clean data.
const { isEnabled: draft } = await draftMode()
await payload.find({
collection: 'pages',
draft,
context: { visualEditing: draft },
where: { slug: { equals: slug } },
})In either mode context.visualEditing wins when set: true always enriches, false never does. Server code that reads drafts for its own processing should pass context: { visualEditing: false } under 'auto'. The plugin augments Payload's RequestContext, so the key is type-checked.
Why it matters. The
beforeChangestrip only removes intact markers. If server code receives stega, transforms the text (an LLM translation, truncation, concatenation) and writes it back, fragments of a damaged marker survive the strip and end up in the database. Use'explicit'whenever server code reads drafts and writes derived content.
How it works
Server pipeline
beforeOperationstampsreq.contextwith the draft flag.afterRead(gated by the enrichment mode — under'auto', draft reads served to the frontend Local API; admin-panel reads, identified by their admin pathname, and REST reads are skipped) walks the returned document, attaches_meta.pathmarkers to leaf-ish objects, and for rich-text / primitive-terminal types sets_meta.terminal = trueso outer collection walks don't clobber them.afterOperationuses the collection's schema to embed Vercel stega into text fields, carrying the field path all the way through SSR into the client DOM.- Before stega is embedded, a small pre-pass walks each enriched doc's schema against its data. Populated
upload:<slug>values are flipped to_meta.terminal = trueso their identity is preserved for wrapper-attr consumption on the client (<img {...withVisualEditingPath(upload)} />), without embedding zero-width stega intoaltorfilename. beforeChangestrips intact stega markers from every write as a safety net. Markers damaged by a text transformation are not recognized — keep stega away from such code with the enrichment mode.
The schema cache (per slug, memoized) resolves relationship targets lazily so you don't pay for unused collections.
Client overlay
VisualEditing.Overlay mounts a MutationObserver inside its effect, scans existing DOM for stega-bearing text, and redraws edit badges on mount, lazy-mount, and DOM mutations. Clicking a badge reaches VisualEditingBridgeProvider (via postMessage to the parent admin when framed, or window.opener when opened from an admin tab) → schema walk → field focus.
Options
| Option | Type | Default | Purpose |
|---|---|---|---|
| skipCollections | string[] | [] | Exclude these collection slugs from enrichment. Payload internal collections are always skipped. |
| skipGlobals | string[] | [] | Exclude these global slugs from enrichment. |
| excludeFieldNames | string[] | [] | Extra field names to strip from the serialized schema. Always merged with _status, folder, slug. |
| adminBasePath | string | /admin | Payload admin base path. Used to exclude admin reads from enrichment and by the bridge for URL parsing and admin-tab navigation. |
| enrichment | 'auto' \| 'explicit' | 'auto' | Which reads get stega: any frontend draft read, or only reads passing context: { visualEditing: true }. See Enrichment modes. |
The VisualEditing.Provider (client) also accepts framedOnly (restrict the overlay to the CMS preview iframe) and adminBasePath (must match the server option).
Caveats
target="_blank"must not carrynoopener. The postMessage bridge requireswindow.openerto be non-null. If you open the frontend from the admin tab with a plain anchor, browsers defaultwindow.openertonullontarget="_blank"unless you explicitly opt out. Usewindow.open(url, '_blank', 'noopener=no')or cross-tab messaging will fail silently. (Not a concern in the side-by-side Live Preview, which messages the parent frame.)- The overlay hook intentionally collapses effect deps to
[enabled]viactxRef. Do not "fix" this — parent re-renders would teardown the MutationObserver and lazy-mounted content (React.lazy / Suspense) would stop getting outlined. _meta.terminal = trueon rich-text is load-bearing — prevents outerafterReadwalks from clobbering inner markers when collections relate to other collections.slugis excluded from stega — zero-width chars inside a slug corrupt URLs (become noisy%E2%80%8Bsequences).
License
MIT
