A visual editing system built on top of the Payload Website Template. Admin users can click directly on frontend content to navigate to the exact field in the Payload admin panel — with automatic section expansion, field focus, and a sweep animation.
Status: MVP / Work in Progress
There are two fundamentally different ways to connect frontend content to CMS fields. This section documents both, their tradeoffs, and why this MVP uses the approach it does.
This is the approach pioneered by Sanity and adopted by Vercel's @vercel/stega. It works at the data layer.
How it works:
- The CMS generates content source maps — a JSON mapping from every string value in the API response to its origin field path and document ID.
- At data fetch time, a library like
@vercel/stegaencodes this metadata into string values using invisible zero-width Unicode characters. - On the frontend, a toolbar SDK (like
@vercel/visual-editing) detects hover/click on any text, reads the hidden characters, and knows exactly which CMS field produced that text. - The toolbar then opens an edit intent URL or sends a message to the CMS admin.
CMS Database
↓
API Response + Content Source Map
↓
Stega encoding (invisible chars injected into strings)
↓
Frontend renders strings (with hidden metadata)
↓
Toolbar SDK reads metadata on hover/click
↓
Opens edit UI for the exact field
Advantages:
- Zero per-component work — every string field is automatically editable
- Character-level precision — can highlight the exact text that maps to a field
- Works with server components — encoding happens at data fetch, not render
- Framework-agnostic — works with any frontend (React, Vue, vanilla HTML)
Disadvantages:
- Requires content source maps from the CMS — this is the fundamental blocker
- Only works for string fields — rich text (Lexical), media, relationships, and other complex fields cannot be stega-encoded
- Invisible characters can break
textContentcomparisons, copy-paste, and some SEO tools - Requires a third-party toolbar SDK on the frontend
- No control over the edit UX — you get the toolbar's UI, not your own
The Payload blocker: Payload CMS has zero content source map infrastructure — no @vercel/stega dependency, no source map generation, no field provenance tracking. This is not a gap that can be bridged from the outside. Content source maps require the CMS itself to track the origin of every string value through its entire query pipeline — population of relationships, localization resolution, access control filtering, block/array field traversal, and draft/published version selection. Every string in the API response needs a reverse pointer to its exact database path and document ID. Sanity built this into their query engine (GROQ) from the ground up; for Payload, this would mean fundamental changes to how payload.find() and the REST/GraphQL APIs resolve and return data. It is not on Payload's public roadmap.
This is the approach used here. It works at the component layer.
How it works:
- A
VisualEditingProviderat the page root checks if the user is a Payload admin. SectionContainerwraps each block/section and publishes abasePathvia React context.- "Smart primitives" (
RichText,CMSLink,Media) read the context and wrap themselves inEditableFieldoverlays that know their full field path. - Clicking an overlay sends a
postMessageto the admin panel with the field path. - The
VisualEditingBridgein the admin parses the path, expands sections, and focuses the field.
Page Component
↓
VisualEditingProvider (isAdmin? docId? collectionSlug?)
↓
SectionContainer (basePath="layout.0")
↓ publishes SectionContext
↓
RichText / CMSLink / EditableField
↓ reads basePath, appends field name
↓ renders green overlay on hover
↓
Click → postMessage({ fieldPath: "layout.0.richText" })
↓
VisualEditingBridge (admin)
↓ expands sections → scrolls → focuses → sweep animation
Advantages:
- Works with ALL field types — rich text (Lexical), media, links, relationships, custom fields
- Full control over the overlay UX — custom colors, animations, sweep effects
- No invisible characters polluting content
- No external dependencies — pure React context
- Works with Payload out of the box — no source map infrastructure needed
- Can trigger complex admin interactions (expand collapsibles, switch tabs, focus specific inputs)
Disadvantages:
- Requires per-component work — each field type needs to be wrapped or made "smart"
- Field-level precision only — can't highlight individual characters within a string
- Requires
'use client'— overlays need React state for hover/click - Path construction is manual — wrong paths silently fail (field not found, wrong section expanded)
- Tightly coupled to Payload's admin DOM — relies on CSS class names and ID conventions that may change between versions
| Stega + Source Maps | Context + Annotation (this MVP) | |
|---|---|---|
| CMS requirement | Content source maps (Payload: not available) | Admin user check via API (works today) |
| String fields | Automatic | Need EditableField wrapper |
| Rich text | Not supported | Supported (Lexical focus) |
| Media / uploads | Not supported | Supported |
| Links / relationships | Not supported | Supported |
| Precision | Character-level | Field-level |
| Server components | Compatible | Requires 'use client' |
| Admin interaction | Opens edit URL | Expands sections, focuses input, sweep animation |
| Maintenance | CMS must maintain source maps | Must track Payload DOM conventions |
| Setup per block | Zero | Smart primitives + editField for arrays |
Stega is not a viable option for Payload today and won't be without core CMS changes:
- Payload has no content source maps — the fundamental prerequisite for stega doesn't exist and would require deep changes to Payload's query engine to build.
- Stega only covers strings — even if source maps existed, rich text (Lexical), media uploads, links, and relationships can't be stega-encoded. These are the majority of editable fields in a typical Payload site.
- The context approach works today — no CMS changes needed. It covers all field types and provides richer admin interactions (section expansion, field focus, sweep animations) than a stega toolbar could.
If Payload ever adds content source map support, stega could complement this system for plain text fields (titles, labels, descriptions) — giving character-level precision with zero component work. But the context-based annotation would still be needed for complex fields. The two approaches are complementary, not competing.
The system has three layers:
Frontend (preview) Admin Panel
┌──────────────────────────┐ ┌──────────────────────────┐
│ VisualEditingProvider │ │ VisualEditingBridge │
│ ├─ SectionContainer │──msg──▶│ ├─ parse fieldPath │
│ │ ├─ RichText │ │ ├─ switch tab │
│ │ ├─ CMSLink │ │ ├─ expand rows │
│ │ └─ EditableField │ │ ├─ scroll & focus │
│ └─ ... │ │ └─ sweep animation │
└──────────────────────────┘ └──────────────────────────┘
-
VisualEditingProvider — page-level context that checks if the current user is a Payload admin. Provides
isAdmin,docId,collectionSlug,adminBaseUrlto all descendants. -
SectionContainer + EditableField — overlay components that render green outlines and edit badges on hover. Clicking sends a
ve:open-fieldpostMessage to the admin panel with the dot-separated field path. -
VisualEditingBridge — admin-side component that receives messages, expands collapsed sections, switches tabs, scrolls to the target field, focuses the input, and plays a sweep animation.
Every editable element builds a dot-separated path matching Payload's internal field structure:
layout.0.richText → block 0, richText field
layout.0.links.1.link.label → block 0, links array index 1, link group, label
hero.richText → hero section rich text
layout.3.cards.0.link.label → block 3, cards array index 0, link label
Paths are composed by combining SectionContext.basePath + the field name passed to EditableField.
The bridge maps field paths to Payload's DOM elements:
| Path segment | DOM ID pattern | Example |
|---|---|---|
| Array index | {prefix}-row-{index} |
layout.0 → layout-row-0 |
| Nested array | {parent-dashed}-row-{index} |
layout.0.links.1 → layout-0-links-row-1 |
| Field | field-{path-with-__} |
layout.0.links.1.link.label → field-layout__0__links__1__link__label |
Known exception: Lexical rich text fields do NOT get a field- id on their wrapper. The bridge falls back to the row element and focuses the first top-level field.
// Frontend → Admin
{
type: "ve:open-field",
fieldPath: "layout.2.links.0.link.label",
docId: "abc-123",
collectionSlug: "pages"
}| File | Purpose |
|---|---|
src/providers/VisualEditing/index.tsx |
Page-level context, admin detection via /api/users/me |
src/providers/SectionContext/index.tsx |
Provides basePath to descendant fields |
src/components/SectionContainer/index.tsx |
Block-level overlay (green outline + badge), provides SectionContext |
src/components/EditableField/index.tsx |
Field-level overlay (green outline + badge) |
src/components/VisualEditingBridge/index.tsx |
Admin-side: parses paths, expands rows, focuses fields, sweep animation |
src/components/RichText/index.tsx |
Auto-wraps in EditableField when in admin SectionContext |
src/components/Link/index.tsx |
Auto-wraps in EditableField, supports editField prop for array paths |
src/blocks/RenderBlocks.tsx |
Wraps each layout block in SectionContainer |
src/heros/RenderHero.tsx |
Wraps hero in SectionContainer |
Add the config to src/collections/Pages/index.ts in the blocks array and register its component in src/blocks/RenderBlocks.tsx:
// RenderBlocks.tsx
import { MyNewBlock } from '@/blocks/MyNewBlock/Component'
const blockComponents = {
// ...existing blocks
myNewBlock: MyNewBlock,
}RenderBlocks automatically wraps every block in a SectionContainer with basePath={layout.${index}}.
Three components auto-annotate themselves when inside a SectionContext:
RichText—field="richText"CMSLink—field="link.label"(targets the label input in admin)Media—field="media"
Just render them in your block component. Overlays are automatic.
When a primitive is inside a .map(), pass the correct path with the array index:
// WRONG — sends "link.label", admin can't find the array row
{links.map(({ link }, i) => (
<CMSLink {...link} />
))}
// CORRECT — sends "links.0.link.label", admin expands the right row
{links.map(({ link }, i) => (
<CMSLink {...link} editField={`links.${i}.link`} />
))}For blocks with independently editable sub-items (like cards or columns), wrap each item in its own SectionContainer:
{cards.map((card, index) => {
const cardBasePath = section?.basePath
? `${section.basePath}.cards.${index}`
: undefined
return cardBasePath ? (
<SectionContainer key={index} basePath={cardBasePath}>
{/* primitives here use the card's basePath */}
</SectionContainer>
) : (
<div key={index}>{/* non-admin render */}</div>
)
})}For fields that aren't RichText/CMSLink/Media, wrap manually with EditableField:
import { EditableField } from '@/components/EditableField'
<EditableField field="title" label="Title">
<h1>{title}</h1>
</EditableField>The field value must match the Payload field slug.
- Block config added to Pages collection
- Block component registered in RenderBlocks
- Smart primitives (RichText, CMSLink, Media) used where applicable
- Array-rendered CMSLinks pass
editField={arrayName.${i}.link} - Nested sub-items wrapped in SectionContainer with correct basePath
- Custom fields wrapped in EditableField with matching Payload field slug
- Clicking overlay in preview expands the correct section in admin
- Correct field receives focus after expansion
- Sweep animation plays on the focused field
| Block | Smart primitives used | Notes |
|---|---|---|
| CallToAction | RichText, CMSLink (array) | Links use editField={links.${i}.link} |
| Content | RichText, CMSLink | Columns wrapped in SectionContainer |
| Archive | RichText | Select/relationship fields not yet editable |
| MediaBlock | Media | |
| Banner | RichText | |
| ImageText | RichText, CMSLink, Media | Single link (no array) |
| CardGrid | CMSLink | Cards wrapped in SectionContainer |
| Code | — | No editable primitives |
| Form | — | Form fields not applicable |
When clicking a section or field overlay, the green sweep animation only plays in the admin panel (left side). The preview iframe (right side) does not show any visual feedback confirming what was clicked. Adding a sweep or flash on the preview element that was clicked would improve the feedback loop.
When clicking a deeply nested field, the bridge sequentially expands collapsible rows with 500ms delays. During this expansion, Payload's height transition animation can cause a brief moment of broken/shifting UI before the content settles. The current delays (500ms between expansions, 400ms before focus) are conservative but still occasionally show this artifact. A more robust approach would be to observe DOM mutations or listen for transitionend events instead of using fixed timeouts.
The visual editing overlays currently work in two contexts:
- Inside Payload's live preview iframe — clicks send postMessage to the admin parent
- Standalone page visits — clicking "Edit in CMS" opens the admin in a new tab
In standalone mode, we open the correct admin page but do NOT focus on the specific field. A future improvement would be to pass the fieldPath as a URL parameter (e.g., ?ve-focus=layout.0.richText) and have the VisualEditingBridge read it on page load to auto-expand and focus. This would give the same precise field navigation experience outside of the iframe context.
Payload's Lexical rich text fields do not render a field-{path} id on their wrapper element, unlike text inputs and other field types. The bridge works around this by falling back to the parent row element and focusing the first top-level field. This means if a rich text field is not the first field in a block, the wrong field may receive focus. A more precise solution would be to search for the field by its name attribute or label text.
The current focus selector targets [data-lexical-editor], input:not([type="hidden"]), textarea. React-select based fields (dropdowns, relationship pickers) use a hidden input that doesn't respond to .focus() in a useful way. These field types are not yet supported for auto-focus.
This project is built on the official Payload Website Template.
pnpx create-payload-app my-project -t website
cd my-project && cp .env.example .env
pnpm install && pnpm devOpen http://localhost:3000.
- Users — auth-enabled, access to admin panel
- Pages — layout builder enabled, draft preview
- Posts — blog/news content, layout builder enabled
- Media — uploads with pre-configured sizes and focal point
- Categories — taxonomy for grouping posts
Create unique page layouts using blocks: Hero, Content, Media, Call To Action, Archive, ImageText, CardGrid.
All posts and pages are draft-enabled. The template supports both draft preview (via custom URL redirect) and live preview (SSR rendering while editing).
Pre-configured with official Payload plugins for SEO, search, and redirects.
pnpm build
pnpm startSee the Payload deployment docs for hosting options.