npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-editing

Peer 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>
  )
}
  • available enables the overlay only when draft mode is on; a user-level toggle (stored in localStorage) 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.Toggle renders the floating off/hover/always control; VisualEditing.Overlay scans 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:importmap

Using 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 _meta is 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 with draft: true outside 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 pass context: { 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 beforeChange strip 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

  1. beforeOperation stamps req.context with the draft flag.
  2. 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.path markers to leaf-ish objects, and for rich-text / primitive-terminal types sets _meta.terminal = true so outer collection walks don't clobber them.
  3. afterOperation uses the collection's schema to embed Vercel stega into text fields, carrying the field path all the way through SSR into the client DOM.
  4. 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 = true so their identity is preserved for wrapper-attr consumption on the client (<img {...withVisualEditingPath(upload)} />), without embedding zero-width stega into alt or filename.
  5. beforeChange strips 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 carry noopener. The postMessage bridge requires window.opener to be non-null. If you open the frontend from the admin tab with a plain anchor, browsers default window.opener to null on target="_blank" unless you explicitly opt out. Use window.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] via ctxRef. Do not "fix" this — parent re-renders would teardown the MutationObserver and lazy-mounted content (React.lazy / Suspense) would stop getting outlined.
  • _meta.terminal = true on rich-text is load-bearing — prevents outer afterRead walks from clobbering inner markers when collections relate to other collections.
  • slug is excluded from stega — zero-width chars inside a slug corrupt URLs (become noisy %E2%80%8B sequences).

License

MIT