Skip to Content
Delmare Digital

Puck

Add the Experiment block to payload-puck's render and editor configs, load the editor styles, and render resolved content.

With Puck, editors build block tests in place: an Experiment block in the A/B tests category holds one slot per version. This page wires it into payload-puck. Puck is optional; without it, use Payload blocks.

Works with @delmaredigital/payload-puck >=0.10.3 and payload-experiments >=0.4.0, through payload-puck's integrations option. Neither package depends on the other at runtime: payload-puck works without experiments, and experiments works without Puck.

Register your Puck collections

Tell the plugin which collections hold Puck data, so it can find placements, guard saves during tests, record content changes and make winners permanent.

payload.config.ts
createExperimentsPlugin({
  // ...
  puck: {
    enabled: true,
    collections: ['pages', { slug: 'templates', field: 'content' }],
  },
})

A slug string uses the collection's puckData field; { slug, field } names another field. The TypeScript type requires enabled. With enabled: false, Puck registration adds no collection hooks: no save guards, content-change tracking or click-goal publish checks. Collections also registered under pages or blocks still receive those integrations' hooks.

Setting enabled: false leaves the collection list in place: placement scans and Make permanent still visit registered collections.

Add the render integration

In your render config, pass experimentsPuck() to payload-puck's extendConfig:

src/puck/renderConfig.ts
import { extendConfig } from '@delmaredigital/payload-puck/config'
import { experimentsPuck } from '@delmaredigital/payload-experiments/puck'
import { baseConfig } from './baseConfig'

export const renderConfig = extendConfig({
  base: baseConfig,
  integrations: [experimentsPuck()],
})

Add the editor integration

In a 'use client' module, create the editor integration once at module scope and pass it to PuckConfigProvider. Creating a new object on every render rebuilds Puck's config and plugin list.

src/puck/EditorProvider.tsx
'use client'

import { PuckConfigProvider } from '@delmaredigital/payload-puck/client'
import { experimentsPuckEditor } from '@delmaredigital/payload-experiments/puck/editor'
import { editorConfig } from './editorConfig'

const integrations = [experimentsPuckEditor()]

export function PuckProvider({ children }: { children: React.ReactNode }) {
  return (
    <PuckConfigProvider config={editorConfig} integrations={integrations}>
      {children}
    </PuckConfigProvider>
  )
}

Each integration object contains the Experiment block and its A/B tests sidebar category. The editor object also contains an outline plugin that hides empty version slots the block's test doesn't use; content in an unused slot stays visible with a warning.

Both helpers accept { routes } for non-default Payload admin and API routes. The editor helper also accepts accent; pass the same value as the plugin's theme.accent, since the editor cannot read the Payload config.

experimentsPuck({ routes: { admin: '/cms', api: '/cms-api' } })
experimentsPuckEditor({ routes: { admin: '/cms', api: '/cms-api' }, accent: '#1f3a68' })

Load the editor stylesheet in the iframe

Puck's canvas is an iframe, so the block's editor styles must be in the iframe stylesheet. Import them after Tailwind (if you use it) in the CSS that builds that stylesheet, and list the stylesheet's URL in payload-puck's editorStylesheets:

src/puck/editor-iframe.css
@import "tailwindcss";
@import "@delmaredigital/payload-experiments/puck/editor.css";

Alternatively, serve the package's puck/editor.css from a public URL and append that URL to editorStylesheets after your own stylesheet. The editor CSS has its own light and dark palette, chosen by data-theme="dark" or the dark class on the iframe's <html>.

Resolve before rendering

On the site, resolve the Puck data for the visitor, render the result, and record exposures for what was rendered:

src/components/TestedPage.tsx
import { after } from 'next/server'
import { Render, type Data } from '@puckeditor/core'
import { getAssignments, resolveExperimentData, recordExposures } from '@delmaredigital/payload-experiments/rsc'
import { serverRuntime } from '@/experiments/serverRuntime'
import { renderConfig } from '@/puck/renderConfig'

export async function TestedPage({ puckData }: { puckData: Data }) {
  const assignments = await getAssignments(serverRuntime)
  const { data, rendered } = resolveExperimentData(puckData, assignments)
  after(() => recordExposures(serverRuntime, rendered, assignments))
  return <Render config={renderConfig} data={data} />
}

resolveExperimentData removes the versions this visitor should not see and returns only the block tests actually present with the render policy in rendered. Render inside <Suspense> and outside 'use cache'.

For viewport tests, add a client provider; see Client counting.

In the editor

The Experiment block is a small test builder. While its test is a draft or stopped, editors can add up to three variants, rename them, copy the Original into a version, remove a version and set the traffic split. A running test shows people per version and the current leader. Set up the test and View results open the admin in a new tab, so unsaved Puck changes are kept.

Puck warns before edits that would change content during a running test. Publishing a page whose components name a click goal that does not exist is refused for managers, with the component's location; draft saves are still allowed.

Click goals in Puck components

Add the click-goal picker to button and link components: xpGoalPuckEditorField() from /puck/editor in your editor config (pass { routes } for custom routes), and xpGoalPuckField() from /puck in your render config. See Click goals.

Wiring by hand

The integration objects bundle pieces that stay exported on their own:

  • /puck: ExperimentSlot, createExperimentSlot({ routes }), EXPERIMENT_SLOT_TYPE ('ExperimentSlot') and createExperimentSlotField({ routes }).
  • /puck/editor: ExperimentSlotEditor, createExperimentSlotEditor({ routes, accent }) and experimentOutlinePlugin().

Register the render and editor components under the same EXPERIMENT_SLOT_TYPE key, add a sidebar category listing it, and add experimentOutlinePlugin() to Puck's plugins; it composes with your own outline override. ExperimentSlot and ExperimentSlotEditor use the default /admin and /api routes; use the factories for other routes.

Puck outside the Payload admin

If your Puck editor page is not inside Payload's admin, load Payload's admin styles and the plugin's admin styles, and set data-theme="light" or data-theme="dark" on <html>:

@import "@payloadcms/next/css";
@import "@payloadcms/ui/styles.css";
@import "@delmaredigital/payload-experiments/admin/theme.css";
@import "@delmaredigital/payload-experiments/admin/ui.css";

Inside the admin, all of this is already in place.

Edit this page on GitHub

On this page