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

payload-puck-advance

v0.3.2

Published

A Puck visual editor for existing Payload collections — block definitions stay with Payload

Readme

payload-puck-advance

npm Payload CMS Puck Next.js License

TypeScript React Node.js pnpm SWC ESLint

A Puck canvas for Payload CMS that acts as a bridge, not as a design system.

Payload owns the block definitions. Payload's own form is where blocks are added and arranged. Puck only renders, and its field panel is derived from those same block definitions at runtime.

This package ships no blocks, no components, no tokens, and no CSS.

payloadPuckAdvance({
  collections: ['pages'],
  puckViewComponent: '@/components/PuckView#PuckView',
})

The Puck canvas: block catalogue on the left, the page rendered in the centre, field panel on the right

The canvas renders the page with the application's own components — the same ones the production frontend uses.

Why it is shaped this way

The first version of this package shipped a contract layer of its own: a five-tier taxonomy, a built-in section catalogue, CSS tokens, and a normalisation layer between Payload and the frontend. All of it was removed, for three reasons that only became apparent in real use:

  1. Two sources of truth. Adding a single field meant editing the contract, the Payload block, and the render component — three places that had to be kept in sync by hand. Whichever one was forgotten did not raise an error; it simply went missing.
  2. A catalogue the project did not own. The canvas offered sections that never appeared in the default form, so editors could assemble pages that could not be edited anywhere else.
  3. A third shape. The normalisation layer became a third data shape alongside the Payload document and the component props — and it was invariably the third shape that fell behind.

What remains is one source of truth (the Payload block definitions), one data shape (the blocks rows exactly as stored), and one set of components, used by both the frontend and the canvas.

How it works

src/blocks/Hero.ts          Payload block definition      ← THE single source of truth
      │
      ├──→ Payload's default form      (add and arrange blocks)
      ├──→ Puck field panel            (derived at runtime by this package)
      └──→ src/blocks/render.tsx       (React components)
                 │
                 ├──→ production frontend   via <BlockRenderer />
                 └──→ Puck canvas           via renderMap

What this package does, and nothing beyond it:

  • reads block definitions from Payload's client config (useConfig()) and derives Puck's config.components from them
  • maps blocks rows to Puck data and back again
  • mounts a full-viewport document view at /admin/collections/<slug>/<id>/puck
  • replaces the Live Preview eye icon with a three-mode selector
  • loads and saves through Payload's REST API, honouring drafts and versions
  • optionally revalidates the frontend on publish

With the catalogue enabled the view uses Puck's own layout: an icon rail on the left with Blocks and Outline tabs, a viewport toolbar, and undo/redo in the header. Only Puck's Publish button is replaced — via overrides.headerActions — because publishing is Payload's authority; a Save draft and a Publish button sit there instead, on the same row. The bar above it keeps just the back link, the document title, its draft/published badge, and the save status.

The catalogue lists every block registered in the target collection's blocks field. It is derived from the same Payload definitions that produce the field panel, so adding a block in Payload makes it appear here with no second list to maintain. Blocks can therefore be added either by dragging from the catalogue or through the default form's Add Layout button.

Payload's default edit form, showing the layout blocks field and its Add Layout button

Payload's form is untouched: the layout lives in an ordinary blocks field with its own Add Layout button, and the mode selector sits among the document controls.

Earlier versions hid the catalogue on the grounds that it would be "a second catalogue that could drift". That reasoning was wrong — the list has only ever had one source — and the cost was real: an editor who opened the canvas found no way at all to add content. Set showComponentList: false to restore the previous behaviour — a three-column layout with the outline only, and no tab rail (there would be just one tab left).

Pass categories to group the catalogue into collapsible sections. It takes Puck's own shape ({ key: { title, components, defaultExpanded } }) and slugs that are not listed fall into Puck's other group, so a newly added block can never disappear from the catalogue merely because someone forgot to list it. Slugs listed but absent from the config are dropped: Puck would otherwise render a draggable item with no component behind it.

Installation

pnpm add payload-puck-advance   # or: npm i / yarn add
npx payload-puck-advance init --dry-run   # inspect the plan first
npx payload-puck-advance init
pnpm generate:importmap

init writes files that belong to your project — example block definitions, render components, a Pages collection, a data client, and the frontend routes — and then patches payload.config.ts. Those files are marked as yours: a subsequent init will not overwrite them without --force.

Prefer to wire it up by hand? The minimum required is:

  1. A collection with a blocks field (named layout by default) and versions: { drafts: true }.

  2. src/blocks/render.tsx — a map of blockType to React component.

  3. src/components/PuckView.tsx:

    'use client'
    import { createPuckView } from 'payload-puck-advance/client'
    import { blockComponents } from '@/blocks/render'
    
    export const PuckView = createPuckView({ renderMap: blockComponents })
  4. The plugin in payload.config.ts, as shown at the top of this document.

Options

| Option | Required | Default | Description | | ---------------------- | -------- | -------------------------------------------------- | ----------------------------------------------------------------- | | collections | ✔ | — | Slugs of the collections to attach to. They must already exist. | | puckViewComponent | ✔ | — | Path to your application's Puck view component. | | field | | 'layout' | Name of the blocks field to edit. | | previewModeComponent | | 'payload-puck-advance/client#PreviewModeSelect' | The mode selector; replace it to supply your own UI. | | puckViewPath | | '/puck' | Path of the document view. | | revalidate | | false | { secret, url, headers? } — called on publish. | | disabled | | false | Skip the plugin entirely, for use behind a feature flag. |

The plugin fails at boot if collections is empty, if a slug is absent from the config, or if the target collection has no blocks field under the configured name. This is deliberate: a silent failure here surfaces much later as an empty canvas with no discernible cause.

createPuckView accepts renderMap (required), categories, fieldName, fullScreen, showComponentList, stylesheetFrom, syncHostStyles, and iframeOverride.

Supported field types

Derived automatically into Puck fields:

text · textarea · number · select · radio · checkbox · array · group · blocks (becomes a slot) · row/collapsible/tabs (flattened)

Deliberately not offered in Puck: richText, upload, relationship, join, date, point, code, json, ui.

richText is the most consequential entry on that list. Its value is Lexical JSON; presenting it as a textarea would allow an editor to overwrite it with plain text and destroy the content without warning. Fields of this kind continue to be edited in the default form — Puck simply does not offer them, and their values are left untouched when saving from the canvas.

checkbox becomes a two-value radio, because Puck has no boolean field.

A block selected in the canvas, with its field panel on the right

Selecting a block fills the panel with its fields — labels, types and order all taken from the Payload block definition, not from a second schema.

Three editing modes, without a new field

The Live Preview eye icon is replaced by a selector offering three modes:

| Option | Behaviour | | ---------------- | ------------------------------------- | | Form | Payload's default form (the default) | | Live Preview | Payload's Live Preview, unmodified | | Puck | opens the Puck view in a new tab |

The mode selector among the document controls, in place of the Live Preview eye icon

The selector takes the eye icon's place. Nothing else on the page changes.

Why not the PreviewButton slot

PreviewButton is only rendered when admin.preview is configured, whereas the eye icon in question is button.live-preview-toggler, which has no replacement slot at all. The selector is therefore mounted through beforeDocumentControls, alongside a single CSS rule that hides the original toggler — one line of CSS rather than a fork of an admin component.

Why Puck opens in a new tab

The difference is one of context, not merely of appearance: the canvas maintains its own selection state, undo history, and save action. Stacking it on top of the form would nest two forms (Puck.Fields always renders a <form>, and Payload's entire document layout already sits inside one), which produces a hydration error rather than merely invalid HTML.

Once the tab is open, the selector does not switch to puck. The original tab still shows the form, and labelling it "Puck" would misrepresent what is on screen.

Full viewport, not the space left below the admin shell

Payload does not permit views.edit.root to coexist with custom views. The Puck view is therefore rendered as a position: fixed layer covering the viewport, with body scrolling locked while it is active. The result is equivalent to taking over the page, without giving up the custom view.

The Puck view header

Two buttons with fixed labels: Save draft (#puck-advance-save) and Publish (#puck-advance-publish). Alongside them, a back link to the document and a badge (#puck-advance-doc-status) reporting whether the document is a draft or published.

Earlier versions offered a status dropdown next to a single save button. That put a Publish option immediately beside a button reading Publish, with nothing to distinguish the control that chooses from the control that acts — and the button's label had to change with the dropdown to avoid lying about what it would do. Two buttons make both actions visible at once and remove the question entirely.

Both issue PATCH ...?draft=true, differing only in the _status they send. draft writes to the versions table alone; published genuinely publishes, since Payload gives an explicit _status precedence over the draft=true in the URL.

Saving a draft on an already-published document does not unpublish it: the live version keeps serving and only a working version is added. The badge therefore reports the status returned by the API rather than the one just requested.

Ctrl/Cmd+S saves a draft and never publishes — publishing on a reflex keystroke is not something that can be taken back.

The Puck view header: back link, document title, status badge, undo/redo, and the Save draft and Publish buttons

Both save controls sit on Puck's own header row, beside undo/redo.

Canvas CSS: borrowed from the frontend, not copied

Puck's canvas iframe is empty, and syncHostStyles pulls in the admin stylesheet rather than the frontend's. The visible symptom is a canvas showing unstyled text while Live Preview renders correctly.

The remedy: when the canvas opens, the frontend page (stylesheetFrom, default /) is fetched, its stylesheet tags are read, and they are injected into the iframe via overrides.iframe. Nothing is copied into this package, so a change to the frontend theme reaches the canvas without any rebuild.

Writing blocks

An ordinary Payload definition, with nothing Puck-specific about it:

export const Hero: Block = {
  slug: 'hero',
  fields: [{ name: 'heading', type: 'text', label: 'Heading', required: true }],
  labels: { plural: 'Hero', singular: 'Hero' },
}

The component is used in two contexts, and it must be the very same component:

export const blockComponents = { hero: Hero as BlockComponent }

The map keys must match the block slugs. That is where the frontend and the canvas meet; if the canvas uses different components, it displays something that will never be served.

Slots (blocks within blocks)

A blocks field inside a block becomes a Puck slot. The component receives two props: the slot contents (an array on the frontend, a component in the canvas) and renderSlot:

export const Grid = ({ items, renderSlot }: { items?: unknown; renderSlot?: (v: unknown) => ReactNode }) => (
  <div className="grid gap-6 md:grid-cols-2">{renderSlot ? renderSlot(items) : null}</div>
)

Do not define a slot that admits blocks which themselves contain slots, including the block itself. blockReferences does not break the recursion: "a Column inside a Column" has no base case, and the definition expands until it fails at boot with Maximum call stack size exceeded. The consequence is a single level of nesting — a limit chosen deliberately, not a defect.

The golden rule: no outer margins

A block component controls its own internal padding and never its outer margin. Spacing between blocks is a decision belonging to the page, not to the block: as soon as one block carries mt-*, blocks can no longer be freely reordered.

Notes that will save you time

Tailwind v4 does not scan node_modules. For as long as this package shipped its own components, their classes were silently dropped from the compiled stylesheet — the symptom being an unstyled page whose HTML nevertheless contains the correct classes. Every class now lives in the project's src, so @source is no longer required. Move block components into a package of your own and the trap returns.

Postgres identifiers are limited to 63 characters. Nested blocks produce enum names such as enum_layout_pages_v_blocks_..._new_tab, which exceed the limit and cause the schema push to fail. Shorten the field names, or set dbName and enumName explicitly.

export const dynamic must be a literal. Next reads it statically; dynamic = route.dynamic is ignored without warning, leaving the preview route eligible for caching — which means drafts can be served from cache.

Do not build this package while the dev server is running. Payload is reading dist/, and the mildest consequence is a module disappearing mid-request. build also runs clean first, because swc does not prune files whose sources have been deleted; without it, stale code lingers in dist and presents itself as a change that refuses to take effect.

CLI: payload-puck-advance init

Detected automatically: the package manager, src/, the App Router directory, the route group, the import alias from tsconfig.json, the location of payload.config.ts, and whether the pages slug is already taken (in which case puck-pages is used).

Guarantees: --dry-run writes nothing; existing files are never overwritten without --force (which creates a .bak); the command is safe to run again; and it reports a manual step in preference to guessing.

Config patching is performed by text search rather than by rewriting the TypeScript AST. An AST-based approach looks more capable, but real-world Payload configs vary considerably — wrapped in functions, spread from other files, plugins produced by .map() — and that is precisely where an AST fails quietly. Text search fails openly: where it is not confident, it declines to touch the file and prints a snippet to paste.

The patcher inserts three things: the plugin import, Pages into the collections array (mandatory — the plugin throws if the slug is not registered), and livePreview into admin. Place a // @puck-advance:plugins comment inside the plugins array to choose the insertion point yourself.

Without an import alias, component paths in the config are resolved relative to admin.importMap.baseDir, a value that cannot be inferred from the outside. The CLI reports this as a warning rather than guessing.

Runtime verification

The end-to-end suite in payload-boilerplate/tests/e2e/puck.e2e.spec.ts (21 tests) guards precisely the things that have broken before: the catalogue must show every configured group and list exactly the registered blocks, dragging one onto the canvas must add it, the outline must contain only the page's actual contents, the field panel must genuinely derive from the Payload block definitions, the full-viewport layer must be in place, the canvas CSS must be applied, there must be no nested forms and no Next error overlay, the back link and status selector must be present, and drafts and publishes must write to the correct place.

Support

If this package saved you time, a contribution is warmly appreciated — though never expected. It goes towards keeping the package current with Payload's releases.

Bug reports and pull requests are equally valuable, and free: open an issue.

License

MIT © Suryo Galih Kencana Harianja. See LICENSE.