Skip to Content
Delmare Digital

Quick start

Wire payload-experiments into a Payload and Next.js app and run a first block test end to end.

This guide adds the plugin to an existing Payload app with a Pages collection edited in Puck, then runs a first test on a block. Imports starting with @/ are files in your app. For a Payload blocks field instead of Puck, swap step 6 for Payload blocks.

Create the two runtimes

The proxy runtime runs in proxy.ts and must not import Payload. It reads the compiled test configuration (the manifest) straight from Postgres through a pg pool on the same database as Payload. Give the pool a finite connection timeout.

src/experiments/proxyRuntime.ts
import { Pool } from 'pg'
import {
  defineExperimentsProxyRuntime,
  createPostgresManifestLoader,
} from '@delmaredigital/payload-experiments/runtime/proxy'

const pool = new Pool({
  connectionString: process.env.DATABASE_URI,
  connectionTimeoutMillis: 5_000,
})

export const proxyRuntime = defineExperimentsProxyRuntime({
  secret: process.env.XP_SECRET!,
  apiRoute: '/api', // match Payload's routes.api
  loadManifest: createPostgresManifestLoader({ pool }),
})

The server runtime wraps the proxy runtime and loads Payload lazily:

src/experiments/serverRuntime.ts
import type { Payload } from 'payload'
import { defineExperimentsServerRuntime } from '@delmaredigital/payload-experiments/runtime/server'
import { proxyRuntime } from './proxyRuntime'

export const serverRuntime = defineExperimentsServerRuntime(proxyRuntime, {
  getPayload: async (): Promise<Payload> => {
    const { getPayload } = await import('payload')
    const { default: config } = await import('@payload-config')
    return getPayload({ config })
  },
})

If Payload uses a custom Postgres schema, pass schemaName to createPostgresManifestLoader.

Add the plugin

Add the plugin to your Payload config. access.manage decides who can create and run tests; it receives { req } and must return a boolean.

src/payload.config.ts
import { fileURLToPath } from 'node:url'
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { createPuckPlugin } from '@delmaredigital/payload-puck/plugin'
import { createExperimentsPlugin } from '@delmaredigital/payload-experiments'
import { serverRuntime } from '@/experiments/serverRuntime'

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET!,
  db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URI! } }),
  collections: [{ slug: 'users', auth: true, fields: [] }],
  admin: {
    user: 'users',
    importMap: { baseDir: fileURLToPath(new URL('.', import.meta.url)) },
    components: { providers: ['/puck/EditorProvider#PuckProvider'] },
  },
  plugins: [
    createPuckPlugin({
      pagesCollection: 'pages',
      editorStylesheets: ['/xp-puck-editor.css'],
    }),
    createExperimentsPlugin({
      runtime: serverRuntime,
      access: { manage: ({ req }) => Boolean(req.user) },
      puck: { enabled: true, collections: ['pages'] },
    }),
  ],
})

The example permits every authenticated user to manage tests. Replace access.manage with your app's role check if you need a narrower gate. Merge this with your existing collections and plugins; if Puck already creates pages, keep just one createPuckPlugin() call.

events.rateLimit receives { req, unitId } and returns true to accept or false to drop a browser event. There is no built-in limiter. Before accepting browser events in production, supply one that works across your instances (for example, backed by Redis). It is optional and omitted from this example.

Register the editor integration in the provider referenced by your Payload config:

src/puck/EditorProvider.tsx
'use client'

import type { ReactNode } from 'react'
import { PuckConfigProvider } from '@delmaredigital/payload-puck/client'
import { editorConfig } from '@delmaredigital/payload-puck/config/editor'
import { experimentsPuckEditor } from '@delmaredigital/payload-experiments/puck/editor'

const integrations = [experimentsPuckEditor()]

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

Copy the package's editor CSS to the public URL configured above. Repeat this after package upgrades:

mkdir -p public
cp node_modules/@delmaredigital/payload-experiments/dist/puck/editor/editor.css public/xp-puck-editor.css

Migrate and regenerate the import map

The plugin adds collections and a global; your app owns their migrations. Generate the migration, review it, apply it, then regenerate the import map so the Tests screens and goal pickers load.

pnpm exec payload migrate:create
pnpm exec payload migrate
pnpm exec payload generate:importmap

Repeat this after every upgrade that changes the schema.

Add the proxy

The proxy assigns each visitor a version and handles whole-page rewrites.

src/proxy.ts
import { createExperimentProxy } from '@delmaredigital/payload-experiments/proxy'
import { proxyRuntime } from '@/experiments/proxyRuntime'

export const proxy = createExperimentProxy(proxyRuntime)

If you already have a proxy, compose with resolveExperiments and applyExperiments instead; see Proxy.

Add a results route

Results are computed by runResults, called from a route you schedule and authenticate. The plugin does not use Payload's jobs queue.

src/app/api/cron/xp-results/route.ts
import { runResults } from '@delmaredigital/payload-experiments/rsc'
import { serverRuntime } from '@/experiments/serverRuntime'

export async function GET(request: Request) {
  const secret = process.env.CRON_SECRET
  if (!secret || request.headers.get('authorization') !== `Bearer ${secret}`) {
    return new Response('Unauthorized', { status: 401 })
  }
  const payload = await serverRuntime.getPayload()
  const result = await runResults(payload, { budgetMs: 20_000 })
  return Response.json(result, { headers: { 'Cache-Control': 'no-store' } })
}

Choose a budgetMs below your platform's route timeout. Schedule the route at least hourly, because the admin tells editors to expect first results within the hour after Start. Your host owns the schedule; the plugin does not set it up. On Vercel, add it to your cron configuration.

Render the assigned version

Resolve the page's Puck data for this visitor before rendering it, then record exposures for what you actually rendered, after the response.

Add a render configuration with the Experiment block:

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

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

Create the renderer. The client provider enables viewport exposures and click goals as well as the server's render exposures:

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

export async function TestedPage({ puckData, path }: { puckData: Data; path: string }) {
  const assignments = await getAssignments(serverRuntime)
  const { data, rendered } = resolveExperimentData(puckData, assignments)
  const clientAssignments = toClientAssignments(assignments, { pathname: path })
  after(() => recordExposures(serverRuntime, rendered, assignments))
  return (
    <ExperimentsProvider value={clientAssignments}>
      <Render config={renderConfig} data={data} />
    </ExperimentsProvider>
  )
}

Use it in a frontend route, replacing an existing Puck catch-all route if you have one:

src/app/(frontend)/[[...slug]]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import type { Data } from '@puckeditor/core'
import config from '@payload-config'
import { TestedPage } from '@/components/TestedPage'

export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
  const { slug } = await params
  const slugPath = slug?.join('/') || ''
  const payload = await getPayload({ config })
  const { docs } = await payload.find({
    collection: 'pages',
    where: {
      and: [
        { _status: { equals: 'published' } },
        slugPath ? { slug: { equals: slugPath } } : { isHomepage: { equals: true } },
      ],
    },
    limit: 1,
  })
  const page = docs[0]
  if (!page) notFound()

  return (
    <Suspense fallback={null}>
      <TestedPage puckData={page.puckData as Data} path={`/${slugPath}`} />
    </Suspense>
  )
}

Keep TestedPage outside 'use cache': it reads request headers. Keep editor and preview renders out of this path. See Puck for editor styles and click-goal pickers on custom components.

Run your first test

  1. Open a page in Puck and add an Experiment block from the A/B tests category.
  2. Keep the Original, use Copy Original here to make a version, and change it.
  3. Pick a goal, such as a signup or a click goal on a button.
  4. Publish the page. Open the test from the block, follow the setup checks, preview each version, and press Start.
  5. Once the results route has run, open Tests in the admin to read the verdict.

What else to wire

Most sites add a few more pieces:

A typical layout:

FileWhat belongs there
src/experiments/proxyRuntime.tsSecret, manifest pool, API route
src/experiments/serverRuntime.tsLazy Payload loader, session lookup, staff attributes
src/payload.config.tsPlugin, permissions, shared rate limiter, collection registrations
src/proxy.tsVersion assignment and whole-page rewrites
src/app/api/cron/xp-results/route.tsAuthenticated results route
src/components/TestedPage.tsxResolve content and render the assigned version
src/puck/renderConfig.ts, src/puck/EditorProvider.tsxPuck render and editor integrations
src/auth/callbacks.tslinkIdentity after registration and sign-in
Edit this page on GitHub

On this page