Payload blocks
Run block tests in a Payload blocks field without Puck, with the Experiment block, resolver and viewport groups.
If your pages use a Payload blocks field rather than Puck, editors can still test a block in place. The plugin provides an Experiment block that wraps your own blocks, one set per version.
Register the blocks field
createExperimentsPlugin({
// ...
blocks: { collections: [{ slug: 'pages', field: 'layout' }] },
})The field must be a top-level blocks field: it may sit inside rows, collapsibles or unnamed tabs, but not inside a named group, array or named tab. Startup refuses a registered field that is nested that way or that lacks the Experiment block.
Add the Experiment block to the field
Wrap your existing block list with createExperimentBlock:
import { createExperimentBlock } from '@delmaredigital/payload-experiments'
import { contentBlocks } from '@/blocks'
export const Pages = {
slug: 'pages',
versions: { drafts: true },
fields: [
// ...
{
name: 'layout',
type: 'blocks',
blocks: [...contentBlocks, createExperimentBlock({ blocks: contentBlocks })],
},
],
}The Experiment block's slug is xpExperiment (exported as EXPERIMENT_BLOCK_SLUG); pass slug to use another and the same value as blockSlug in the registration. Experiment blocks cannot be nested. Each version appears once in the block.
Migrate and regenerate the import map
The Experiment block adds tables for the block and its slots, their version tables, and enum types for the slot column. Generate and apply the migration, then regenerate the import map for the block's admin components:
pnpm exec payload migrate:create
pnpm exec payload migrate
pnpm exec payload generate:importmapResolve before rendering
import { after } from 'next/server'
import {
getAssignments,
resolveExperimentBlocks,
recordExposures,
} from '@delmaredigital/payload-experiments/rsc'
import { serverRuntime } from '@/experiments/serverRuntime'
import { RenderBlocks } from '@/blocks/RenderBlocks'
export async function TestedPage({ page }: { page: { layout: unknown } }) {
const assignments = await getAssignments(serverRuntime)
const { blocks, rendered } = resolveExperimentBlocks(page.layout, assignments)
after(() => recordExposures(serverRuntime, rendered, assignments))
return <RenderBlocks blocks={blocks} />
}resolveExperimentBlocks(blocks, assignments, { blockSlug? }) replaces each Experiment block with the visitor's version (the Original for people outside the test and for holdouts, or the winner after a decision) and returns:
| Field | Contents |
|---|---|
blocks | Your host blocks, in order, with Experiment blocks replaced. Blocks that came from a test carry xpGroup. |
rendered | Exposures to record for render-policy tests |
groups | Index ranges in blocks for each test's content |
Use the same Suspense, preview and prefetch rules as for Puck: render inside <Suspense>, outside 'use cache', and keep editor and preview renders out of this path.
Viewport tests
For tests that count on scroll (the viewport policy), wrap each test's blocks in ExperimentGroup from /client, inside a mounted client provider. segmentExperimentBlocks from /blocks splits the resolved list into runs that share a group:
import { segmentExperimentBlocks } from '@delmaredigital/payload-experiments/blocks'
import { ExperimentGroup } from '@delmaredigital/payload-experiments/client'
{segmentExperimentBlocks(blocks).map((run, i) =>
run.group
? <ExperimentGroup key={i} expose={run.group.expose}><RenderBlocks blocks={run.blocks} /></ExperimentGroup>
: <RenderBlocks key={i} blocks={run.blocks} />,
)}Rendering without the plugin
controlOnlyBlocks(blocks, blockSlug?) from /blocks returns your blocks with every Experiment block replaced by its Original. Use it where experiments should not apply, such as a feed or an export.
In the admin
On a registered collection, editors get Test a block on this page. The Experiment block shows its test's status, labels each version, offers Copy the Original into this version, keeps version slots in step with the test, and can be unwrapped back to plain blocks. Wrapping respects document locks.
Puck and blocks on one collection
If a collection is registered in both puck and blocks, give the blocks registration an appliesTo(doc) function that returns true for documents edited with the blocks field. Startup refuses the configuration without it.

