@focus-reactive/payload-plugin-ab
v2.3.1
Published
A/B testing plugin for Payload CMS
Readme
@focus-reactive/payload-plugin-ab
A/B testing plugin for Payload CMS v3 + Next.js. Create page variants directly inside the Payload admin, assign traffic percentages, and route users at the edge — no extra collections, no manual wiring.
Variants live in the same collection as the original page. The plugin injects the admin UI, hooks, and a variant manifest that Next.js middleware reads to route traffic without a database round-trip.
AI Integration Prompt
Copy and paste this prompt into your AI assistant (Cursor, Claude, etc.) to integrate the plugin into an existing Payload + Next.js project.
I want to add A/B testing to my Payload CMS v3 + Next.js project using @focus-reactive/payload-plugin-ab.
## How it works
Variants are regular documents in the same collection as the original page, distinguished by an
injected `_abVariantOf` relationship field. The plugin auto-injects:
- A Variants panel (sidebar) on original pages — lists variants, traffic % inputs, "+ Add new" button
- A "Variant of" badge (sidebar) on variant pages — links back to the original
- Hooks that maintain a variant manifest (a path-keyed JSON map) in a storage adapter
- A `POST /api/_ab/duplicate` endpoint that clones a page with slug `original--{hash}`
The manifest is read by Next.js middleware at the edge to rewrite requests to the correct variant.
## Installation
pnpm add @focus-reactive/payload-plugin-ab
## Step 1 — Register the plugin in payload.config.ts
import { abTestingPlugin } from '@focus-reactive/payload-plugin-ab'
import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
const abAdapter = payloadGlobalAdapter({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
})
// Inside buildConfig({ plugins: [...] })
abTestingPlugin({
storage: abAdapter,
collections: {
// Key = slug of any collection you want to A/B test
pages: {
generatePath: ({ doc, locale }) => {
const slug = doc.slug as string | undefined
if (!slug) return null
return locale ? `/${locale}/${slug}` : `/${slug}`
},
// generateVariantData is optional — auto-generates:
// { bucket: variantSlug, rewritePath: generatePath(variantDoc), passPercentage }
},
},
})
## Step 2 — Add Next.js middleware
import { createResolveAbRewrite } from '@focus-reactive/payload-plugin-ab/middleware'
import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
const storage = payloadGlobalAdapter({ serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '' })
const resolveAbRewrite = createResolveAbRewrite({
storage,
getBucket: (v) => v.bucket,
getRewritePath: (v) => v.rewritePath,
getPassPercentage: (v) => v.passPercentage,
})
export async function middleware(request) {
const { pathname } = request.nextUrl
const result = await resolveAbRewrite(request, pathname, pathname, pathname)
return result ?? NextResponse.next()
}
export const config = { matcher: ['/((?!_next|api|favicon.ico).*)'] }
## Important notes
- generatePath must return a non-null path for variant slugs (e.g. "about--4ji9") since it
is also used to compute the variant's rewrite path when generateVariantData is omitted.
- Variant documents are hidden from the collection list view automatically.
- The slug field (and tenantField if configured) is read-only on variant documents.
- Percentage rules: each variant must have at least 1% traffic. The sum of all variant percentages
cannot exceed 99% — the original always receives at least 1%. The admin UI enforces this live;
the field also validates server-side.
- Draft-aware percentages: % changes are saved with the parent document's save/publish cycle.
Saving as draft stages the new percentages without affecting the live manifest. Publishing
applies them. The manifest always reflects published values only.
- For Vercel deployments, swap payloadGlobalAdapter for vercelEdgeAdapter for sub-millisecond
manifest reads from Vercel Edge Config.
- If the project uses Payload localization, locale-aware manifest entries are written automatically
— no extra config needed.How It Works
Variants live inside the same collection as the original page:
pages collection
├── original slug: "about" _abVariantOf: null
├── variant A slug: "about--4ji9" _abVariantOf: <original id>
└── variant B slug: "about--x2k1" _abVariantOf: <original id>On every save or delete, hooks recompute the variant manifest — a plain JSON object keyed by URL path — and write it to the storage adapter. Next.js middleware reads the manifest at the edge to rewrite the request before any rendering happens.
Payload Admin → afterChange / afterDelete hooks → Storage Adapter
↓
{ "/about": [variantA, variantB] }
↓
Next.js Middleware (edge)
↓
Route user to original or variantInstallation
pnpm add @focus-reactive/payload-plugin-abFor the Vercel Edge adapter, also install:
pnpm add @vercel/edge-configPeer dependencies: payload ^3.0.0. next ^14 || ^15 and react ^18 || ^19 are optional, required only for middleware and analytics respectively.
Quick Start
Step 1 — Register the plugin
// payload.config.ts
import { buildConfig } from 'payload'
import { abTestingPlugin } from '@focus-reactive/payload-plugin-ab'
import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
const abAdapter = payloadGlobalAdapter({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
})
export default buildConfig({
plugins: [
abTestingPlugin({
storage: abAdapter,
collections: {
pages: {
generatePath: ({ doc, locale }) => {
const slug = doc.slug as string | undefined
if (!slug) return null
return locale ? `/${locale}/${slug}` : `/${slug}`
},
},
},
}),
],
})The plugin injects into every configured collection:
- Variants panel (sidebar on original pages) — lists variants with % inputs, add, delete
- "Variant of" read-only relationship (sidebar on variant pages)
_abVariantOf,_abPassPercentage, and_abVariantPercentagesfields (hidden from editors)admin.baseListFilterto hide variant documents from the list viewbeforeChange/afterChange/afterDeletehooks
Step 2 — Wire up middleware
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createResolveAbRewrite } from '@focus-reactive/payload-plugin-ab/middleware'
import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
const storage = payloadGlobalAdapter({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
})
const resolveAbRewrite = createResolveAbRewrite({
storage,
getBucket: (v) => v.bucket,
getRewritePath: (v) => v.rewritePath,
getPassPercentage: (v) => v.passPercentage,
})
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const result = await resolveAbRewrite(request, pathname, pathname, pathname)
return result ?? NextResponse.next()
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico).*)'],
}Configuration Reference
Plugin Options
interface AbTestingPluginConfig<TVariantData extends object> {
storage: StorageAdapter<TVariantData>
collections: Record<string, CollectionABConfig<TVariantData>>
enabled?: boolean // default: true
debug?: boolean // show manifest Global in Payload admin. default: false
}CollectionABConfig
interface CollectionABConfig<TVariantData = DefaultVariantData> {
/**
* Slug field name used to generate variant slugs: `{original}--{hash}`.
* Default: 'slug'
*/
slugField?: string
/**
* Dot-notation path to the tenant field.
* Scopes percentage validation per tenant; locks the field read-only on variants.
*/
tenantField?: string
/**
* Returns the URL path used as the manifest key.
* Return null to skip this document.
* Called once per locale when localization is enabled.
*
* Must also return a valid path for variant slugs (e.g. 'about--4ji9')
* since it is used to compute rewritePath when generateVariantData is omitted.
*/
generatePath: (args: {
doc: Record<string, unknown>
locale: string | undefined
}) => string | null
/**
* Builds the data stored per variant in the manifest.
* When omitted, auto-generates: { bucket: variantSlug, rewritePath, passPercentage }
*/
generateVariantData?: (args: {
doc: Record<string, unknown> // parent document
variantDoc: Record<string, unknown> // variant document
locale: string | undefined
}) => TVariantData
}
// Default shape when generateVariantData is omitted:
type DefaultVariantData = {
bucket: string // variant's slug, e.g. 'about--4ji9'
rewritePath: string // result of generatePath(variantDoc, locale)
passPercentage: number // _abPassPercentage field value
}StorageAdapter
interface StorageAdapter<TVariantData extends object> {
write(path: string, variants: TVariantData[], payload: Payload): Promise<void>
read(path: string): Promise<TVariantData[] | null> // must be edge-compatible
clear(path: string, payload: Payload): Promise<void>
createGlobal?(debug: boolean): GlobalConfig // optional, used by payloadGlobalAdapter
}Implement this interface to use any backend (Redis, Upstash, Vercel KV, etc.).
Storage Adapters
payloadGlobalAdapter
Stores the manifest in a Payload Global as a JSON field. Middleware fetches it via the Payload REST API.
import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
const storage = payloadGlobalAdapter({
serverURL: 'https://cms.example.com', // required for middleware reads
globalSlug: '_abManifest', // default
apiRoute: '/api', // default
})The Global is hidden from the admin by default. Set debug: true in plugin options to expose it under the System group.
vercelEdgeAdapter
Stores the manifest in Vercel Edge Config for sub-millisecond reads at the edge.
import { vercelEdgeAdapter } from '@focus-reactive/payload-plugin-ab/adapters/vercel-edge'
const storage = vercelEdgeAdapter({
configID: process.env.EDGE_CONFIG_ID!,
configURL: process.env.EDGE_CONFIG!,
vercelRestAPIAccessToken: process.env.VERCEL_REST_API_ACCESS_TOKEN!,
teamID: process.env.VERCEL_TEAM_ID, // optional
manifestKey: 'ab-testing', // default
})Required env vars: EDGE_CONFIG, EDGE_CONFIG_ID, VERCEL_REST_API_ACCESS_TOKEN.
Create an Edge Config store in the Vercel dashboard and install @vercel/edge-config.
Middleware
createResolveAbRewrite
import { createResolveAbRewrite } from '@focus-reactive/payload-plugin-ab/middleware'
const resolveAbRewrite = createResolveAbRewrite({
storage, // same adapter as the plugin
getBucket: (v) => v.bucket, // extract bucket key
getRewritePath: (v) => v.rewritePath, // extract internal rewrite URL
getPassPercentage: (v) => v.passPercentage, // enables weighted routing
})
// In middleware:
const result = await resolveAbRewrite(
request,
pathname, // visiblePathname — used as the per-path cookie key
pathname, // manifestKey — key to look up in the manifest
pathname, // originalRewritePath — where to send 'original' bucket users
)The three path arguments let you decouple what the user sees, the manifest key, and the internal rewrite target — useful with locale prefixes:
const internalPath = `/${locale}${pathname}`
await resolveAbRewrite(request, pathname, internalPath, internalPath)Routing: When getPassPercentage is provided, each variant receives its percentage of traffic; the remainder goes to the original. Each variant must have at least 1% and the sum cannot exceed 99% — the original always receives at least 1%. When getPassPercentage is omitted, all variants and the original share equal probability.
Sticky sessions: Bucket assignment is stored in a per-path cookie so returning users always see the same variant.
Cookie System
| Cookie | Default name | Lifetime | Purpose |
|---|---|---|---|
| Bucket | payload_ab_bucket_{path} | Session | Which bucket this user is in |
| Visitor ID | ab_visitor_id | 365 days | Persistent visitor identifier for analytics |
| Experiment | exp_{path} | 90 days | Bucket name readable client-side by analytics hooks |
Override names by passing a cookies config to createResolveAbRewrite:
import type { AbCookieConfig } from '@focus-reactive/payload-plugin-ab/middleware'
// Define once, share between middleware and analytics
export const abCookies: AbCookieConfig = {
visitorIdCookieName: 'my_visitor_id',
getExpCookieName: (key) => `ab_exp_${key}`,
}Use resolveAbCookieNames(config, experimentId) in Server Components to derive cookie names as plain strings for passing to Client Components:
import { resolveAbCookieNames } from '@focus-reactive/payload-plugin-ab/middleware'
const { variantCookieName, visitorCookieName } = resolveAbCookieNames(abCookies, '/en/about')Analytics
Pluggable adapter-based system. Install at app root, then use React components and hooks on pages.
Setup
// app/layout.tsx
import { ABAnalyticsProvider } from '@focus-reactive/payload-plugin-ab/analytics/client'
import { googleAnalyticsAdapter } from '@focus-reactive/payload-plugin-ab/analytics/adapters/google-analytics'
const analytics = googleAnalyticsAdapter({
measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID!,
})
export default function RootLayout({ children }) {
return <ABAnalyticsProvider adapter={analytics}>{children}</ABAnalyticsProvider>
}Tracking impressions
Drop ExperimentTracker anywhere inside a page — fires one impression per browser session:
// app/[locale]/[slug]/page.tsx (Server Component)
import { ExperimentTracker } from '@focus-reactive/payload-plugin-ab/analytics/client'
import { resolveAbCookieNames } from '@focus-reactive/payload-plugin-ab/middleware'
export default async function Page({ params }) {
const experimentId = `/${params.locale}/${params.slug}`
const { variantCookieName, visitorCookieName } = resolveAbCookieNames(undefined, experimentId)
return (
<>
{/* page content */}
<ExperimentTracker
experimentId={experimentId}
variantCookieName={variantCookieName}
visitorCookieName={visitorCookieName}
/>
</>
)
}Tracking conversions
'use client'
import { useABConversion } from '@focus-reactive/payload-plugin-ab/analytics/client'
export function CTAButton({ experimentId, variantCookieName, visitorCookieName }) {
const trackConversion = useABConversion({ experimentId, variantCookieName, visitorCookieName })
return <button onClick={() => trackConversion({ goalId: 'cta_click' })}>Get Started</button>
}Google Analytics Adapter
import { googleAnalyticsAdapter } from '@focus-reactive/payload-plugin-ab/analytics/adapters/google-analytics'
const analytics = googleAnalyticsAdapter({
measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID!, // required
apiSecret: process.env.GA4_API_SECRET, // enables server-side tracking
propertyId: process.env.GA4_PROPERTY_ID, // enables getStats()
getAccessToken: () => auth.getAccessToken(), // enables getStats()
})Events sent: ab_impression and ab_conversion (names configurable) with experiment_id, variant_bucket, visitor_id, and any extra metadata.
Implement the AnalyticsAdapter interface to connect any other analytics backend.
Multi-Tenant Support
abTestingPlugin({
storage,
collections: {
pages: {
tenantField: 'tenant', // dot-notation path to the tenant field
generatePath: ...,
},
},
})When set, percentage-sum validation is scoped per tenant, and the tenant field is locked read-only on variant pages.
Manifest key collisions in multi-tenant projects
Important: If multiple tenants can have pages with the same slug (e.g. both
acmeandglobexhave a page at/en/about), you must include the tenant identifier in thegeneratePathreturn value. Without it, the two pages share the same manifest key and the second save overwrites the first.
generatePath — include the tenant slug:
generatePath: ({ doc, locale }) => {
const tenant = (doc.tenant as { slug?: string } | undefined)?.slug
const slug = doc.slug as string | undefined
if (!tenant || !slug) return null
return locale ? `/${tenant}/${locale}/${slug}` : `/${tenant}/${slug}`
// e.g. '/acme/en/about', '/globex/en/about'
}Middleware — use the same tenant-aware key:
Your createResolveAbRewrite call must derive the manifest key in exactly the same way so that lookups match the stored entries:
// middleware.ts
import { createResolveAbRewrite } from '@focus-reactive/payload-plugin-ab/middleware'
const resolveAbRewrite = createResolveAbRewrite({ storage })
export async function middleware(request: NextRequest) {
const { pathname, searchParams } = request.nextUrl
const tenant = resolveTenantFromRequest(request) // your tenant-resolution logic
const locale = searchParams.get('locale') ?? 'en'
// Build the same key that generatePath produces
const manifestKey = `/${tenant}/${locale}${pathname}`
return resolveAbRewrite(request, { path: manifestKey })
}The key principle: generatePath defines the shape of every manifest key — your middleware must construct the lookup key using identical logic.
Localization Support
No extra config needed. When payload.config.localization is set, the plugin iterates every locale on each save and writes a separate manifest entry per locale. locale is undefined when localization is not configured.
generatePath: ({ doc, locale }) => {
const slug = doc.slug as string | undefined
if (!slug) return null
return locale ? `/${locale}/${slug}` : `/${slug}` // '/en/about', '/fr/about'
}Exports Reference
| Import path | Exports |
|---|---|
| @focus-reactive/payload-plugin-ab | abTestingPlugin, AbTestingPluginConfig, CollectionABConfig, StorageAdapter |
| @focus-reactive/payload-plugin-ab/adapters/payload-global | payloadGlobalAdapter |
| @focus-reactive/payload-plugin-ab/adapters/vercel-edge | vercelEdgeAdapter |
| @focus-reactive/payload-plugin-ab/middleware | createResolveAbRewrite, resolveAbCookieNames, AbCookieConfig, ResolveAbRewriteConfig |
| @focus-reactive/payload-plugin-ab/analytics | Types: AnalyticsAdapter, TrackImpressionArgs, TrackConversionArgs, ExperimentStats |
| @focus-reactive/payload-plugin-ab/analytics/client | ABAnalyticsProvider, ExperimentTracker, useABConversion |
| @focus-reactive/payload-plugin-ab/analytics/adapters/google-analytics | googleAnalyticsAdapter, GoogleAnalyticsAdapterConfig |
