payload-puck-advance
v0.3.2
Published
A Puck visual editor for existing Payload collections — block definitions stay with Payload
Maintainers
Readme
payload-puck-advance
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 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:
- 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.
- 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.
- 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 renderMapWhat this package does, and nothing beyond it:
- reads block definitions from Payload's client config (
useConfig()) and derives Puck'sconfig.componentsfrom them - maps
blocksrows 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 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:importmapinit 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:
A collection with a
blocksfield (namedlayoutby default) andversions: { drafts: true }.src/blocks/render.tsx— a map ofblockTypeto React component.src/components/PuckView.tsx:'use client' import { createPuckView } from 'payload-puck-advance/client' import { blockComponents } from '@/blocks/render' export const PuckView = createPuckView({ renderMap: blockComponents })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.

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 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.

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.
