Skip to Content
Delmare Digital

Whole pages

Register a pages collection for whole-page tests, and wire SEO metadata, the sitemap and pageview counting.

A whole-page test compares separate published pages. The proxy rewrites each request for a page in the test to the visitor's assigned page, so everyone sees one consistent version at the Original's address.

Register the pages collection

Register each collection whose documents can be tested, with a way to derive a document's public path and to find a published page by path:

payload.config.ts
createExperimentsPlugin({
  // ...
  pages: {
    collections: [{
      slug: 'pages',
      pathOf: (doc) => (typeof doc.slug === 'string' ? `/${doc.slug}` : null),
      findByPath: async (path, req) => {
        const { docs } = await req.payload.find({
          collection: 'pages',
          req,
          overrideAccess: false,
          draft: false,
          depth: 0,
          limit: 1,
          where: {
            and: [
              { slug: { equals: path.slice(1) } },
              { _status: { equals: 'published' } },
            ],
          },
        })
        return docs[0] ? { id: docs[0].id, published: true } : null
      },
      contentOf: (doc) => doc.layout,
    }],
  },
})

Adapt both callbacks to your homepage's path. Registered collections must exist before this plugin runs; an unknown slug throws Unknown experiments page collection at startup.

Prop

Type

With contentOf, published copies are compared with nested id and blockName keys removed: identical versions warn, and versions at the same address are refused. Without it, content checks and content-change comparisons are skipped.

Registering the collection also adds a sidebar test status field and content-change hooks to it. Run payload generate:importmap after adding it.

Making a version page

The admin's A whole page flow walks editors through it: use Payload's Duplicate action on the Original, give the copy its own address, publish it, and choose it as a version in the test. The default copy link opens the collection's list in the admin; copyHint, copyUrl and copyLabel customise it.

What the plugin guards

  • A test cannot start while a version page is unpublished.
  • Pages in a running or decided test cannot be deleted, renamed or unpublished (including trash and version restores). The editor is told which test to stop or discard.
  • Starting a test and changing one of its pages at the same moment cannot both succeed; one waits for the other, and the later one is refused.
  • A page test cannot start while another running or decided test uses one of its pages, or while a block or Puck test is on one of its pages.
  • Deciding a stopped page test is refused when the Original or the winner page is unpublished (page-target-unpublished).

These checks run at Start and during the test. They do not stop someone from deleting a page later, after the test's pages are released.

Do not swallow errors from nested updates to guarded page collections inside your own hooks. Payload's Local API does not call afterError, so a swallowed error leaves the guard's state in place for the rest of the request.

SEO metadata

In a dynamic generateMetadata, merge experimentMetadata after your own metadata, so its canonical and robots settings win:

src/app/[...slug]/page.tsx
import type { Metadata } from 'next'
import { experimentMetadata } from '@delmaredigital/payload-experiments/rsc'
import { serverRuntime } from '@/experiments/serverRuntime'

export async function generateMetadata({ params }: { params: Promise<{ slug?: string[] }> }): Promise<Metadata> {
  const { slug } = await params
  const path = `/${(slug ?? []).join('/')}`
  const own = await pageMetadata(path)
  return { ...own, ...(await experimentMetadata(serverRuntime, path)) }
}

What it returns:

  • While a test is live or decided, a variant page reached at its own address gets noindex and a canonical to the Original.
  • The Original's address is never noindex, and while a test is live it gets a canonical to itself, even when it serves a variant's route.
  • After archiving, losing addresses get a canonical to the winner.

It reads the signed request context with headers(), so it decides from the address the visitor requested, not the rewritten route. Call it only from a dynamic generateMetadata, never inside 'use cache'. path is the fallback when the proxy did not run. Set metadataBase so relative canonicals resolve to your site.

Sitemap

experimentSitemapFilter(path, manifest) from /rsc is a synchronous filter for build-time sitemaps. Pass a manifest you loaded privately on the server and keep the paths for which it returns true. It excludes live variant pages and archived losing paths.

Counting pageviews

Page tests count with the pageview policy. On each page in a test, render a nested ExperimentsProvider built for that page's pathname, with <ExperimentPageview /> inside it. See Client counting.

Testing your SEO wiring

pageKindSeoContract({ secret, original, versions, state, winner? }) from /rsc returns { manifest, rows } for a host-level test of your metadata and sitemap. States are running, decided and archived; pass a winner path for the last two. Build a test runtime with loadManifest: async () => manifest and the same secret, then for each row set next/headers to its signed headers, render metadata for servedPath, and assert expect.robots, expect.canonical and expect.inSitemap. The rows include the Original served as each version, including the homepage. Mock four boundaries: next/headers, your page loader, the runtime and the site URL.

Edit this page on GitHub

On this page