Skip to Content
Delmare Digital

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

payload.config.ts
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:

src/collections/Pages.ts
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:importmap

Resolve before rendering

src/components/TestedPage.tsx
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:

FieldContents
blocksYour host blocks, in order, with Experiment blocks replaced. Blocks that came from a test carry xpGroup.
renderedExposures to record for render-policy tests
groupsIndex 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.

Edit this page on GitHub

On this page