payload-plugin-ab
SkillFiles & storageUse this skill for anything involving the payload-plugin-ab Payload CMS plugin. Triggers include: installing payload-plugin-ab, configuring its options, asking what it does, troubleshooting errors, upgrading versions, setting up A/B testing, configuring storage adapters, writing Next.js middleware for variant routing, setting up analytics tracking, and answering questions about its API. If the user mentions "payload-plugin-ab", "A/B testing plugin", "variant routing", "ab testing", "abTestingPlugin", or "variant manifest" in any Payload CMS context, always use this skill.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the payload-plugin-ab skill
What this skill tells your AI
The instructions your AI receives, as published by focusreactive/payload-plugins in packages/payload-plugin-ab/.claude/skills/payload-plugin-ab/SKILL.md and read by ahel’s review.
Adds A/B testing to Payload CMS + Next.js: create page variants in the admin UI, route traffic at the edge via Next.js middleware, and stamp GA4 custom dimensions for analytics attribution via
ExperimentTracker.
Source: github.com/focusreactive/payload-plugins
npm: @focus-reactive/payload-plugin-ab
Payload versions: 3.x
Quick Start
1. Installation
pnpm add @focus-reactive/payload-plugin-ab
# or
npm install @focus-reactive/payload-plugin-ab
Peer dependencies: payload ^3.0.0, next ^14 || ^15, react ^18 || ^19.
For Vercel Edge Config storage: pnpm add @vercel/edge-config
2. 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}`;
},
},
},
}),
],
// ... rest of your config
});
3. Add Next.js 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).*)"],
};
4. Regenerate the import map
npx payload generate:importmap
# or: bunx payload generate:importmap
Without this, the Variants sidebar panel will not appear in the admin UI.
Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable/disable the plugin entirely |
debug | boolean | false | Show the _abManifest global in the admin sidebar |
collections | Record<string, CollectionABConfig> | — | Required. Key = collection slug |
storage | StorageAdapter | — | Required. Where to store the variant manifest |
CollectionABConfig
interface CollectionABConfig<TVariantData = DefaultVariantData> {
slugField?: string; // field used to generate variant slugs (default: "slug")
tenantField?: string; // dot-notation path; locks field on variants, scopes % validation
generatePath(args: {
// REQUIRED: maps doc to manifest key (URL path)
doc: Record<string, unknown>;
locale: string | undefined;
}): string | null;
generateVariantData?(args: {
// optional: custom manifest data shape
doc: Record<string, unknown>;
variantDoc: Record<string, unknown>;
locale: string | undefined;
}): TVariantData;
}
generatePath is the only required function. Return null to skip a document (e.g., drafts or documents without a slug).
generateVariantData is optional. When omitted, the default shape is used:
type DefaultVariantData = {
bucket: string; // variant's slug, e.g. "about--4ji9"
rewritePath: string; // generatePath(variantDoc)
passPercentage: number; // traffic % (1–99)
};
Storage Adapters
payloadGlobalAdapter (recommended for most setups)
Stores the manifest in a Payload Global. Middleware reads it via the Payload REST API.
import { payloadGlobalAdapter } from "@focus-reactive/payload-plugin-ab/adapters/payload-global";
const storage = payloadGlobalAdapter({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? "", // required
globalSlug: "_abManifest", // optional, default: "_abManifest"
apiRoute: "/api", // optional, default: "/api"
});
vercelEdgeAdapter (for sub-millisecond edge reads)
Stores the manifest in Vercel Edge Config. Requires additional env vars.
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", // optional, default: "ab-testing"
});
Required env vars for Vercel Edge: EDGE_CONFIG, EDGE_CONFIG_ID, VERCEL_REST_API_ACCESS_TOKEN.
What the Plugin Adds
_abVariantssidebar field — Rendered on original documents only. Shows variant list with per-variant traffic %, an "Add Variant" button, and delete controls._abVariantOfrelationship field — Read-only, sidebar. Visible on variant documents only; links back to the parent original._abPassPercentagehidden number field — Stores traffic % (1–99) for a variant. Managed via the Variants panel; not exposed as a direct form field._abVariantPercentageshidden JSON field — Buffers pending % changes on the original; synced to variant docs on save.- Modified slug field — Hidden and read-only on variants (auto-generated as
{originalSlug}--{nanoid()}). POST /_ab/duplicateendpoint — Creates a variant by duplicating the parent, setting a new slug and_abPassPercentage: 1.- Hooks —
beforeChangevalidates % sums;afterChangesyncs percentages and recomputes the manifest;afterDeleteclears stale manifest entries. _abManifestglobal — Hidden from admin by default (debug: false). Stores the variant routing table read by middleware.
Usage Patterns
Localization (automatic)
When payload.config.localization is configured, the plugin writes a separate manifest entry per locale automatically. No extra configuration needed — locale is passed to generatePath.
generatePath: ({ doc, locale }) => {
const slug = doc.slug as string;
return locale ? `/${locale}/${slug}` : `/${slug}`;
},
Multi-tenancy
abTestingPlugin({
storage,
collections: {
pages: {
tenantField: "tenant", // percentage validation is scoped per tenant
generatePath: ({ doc, locale }) => {
const tenantId = (doc.tenant as { id: string })?.id;
const slug = doc.slug as string;
return locale ? `/${tenantId}/${locale}/${slug}` : `/${tenantId}/${slug}`;
},
},
},
});
Custom variant data shape
abTestingPlugin({
storage,
collections: {
pages: {
generatePath: ({ doc }) => `/${doc.slug}`,
generateVariantData: ({ variantDoc, locale }) => ({
bucket: variantDoc.slug as string,
rewritePath: `/${locale ?? "en"}/${variantDoc.slug}`,
passPercentage: variantDoc._abPassPercentage as number,
title: variantDoc.title as string, // add extra data for analytics
}),
},
},
});
Analytics integration
Mount <ExperimentTracker experimentId={manifestKey} /> on each variant-served
page. It stamps three GA4 event-scoped custom dimensions on every subsequent event:
fr_ab_experiment— the experiment id (manifest key, e.g./en/about)fr_ab_variant— the assigned bucket (originalor a variant slug)fr_ab_visitor_id— theab_visitor_idcookie value
Register these three dimensions in your GA4 property. The
@focus-reactive/payload-plugin-analytics A/B tab reads them to compute
exposure, conversion-rate, lift, significance, and SRM. Conversions are the
analytics plugin's existing lead_action events on the experiment page — there
is no separate AB conversion event.
The plugin auto-creates an ab-experiments collection (hidden unless debug: true)
that records each experiment's startedAt on first variant publish.
// 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.slug}`;
const { variantCookieName, visitorCookieName } = resolveAbCookieNames(undefined, experimentId);
return (
<>
{/* page content */}
<ExperimentTracker
experimentId={experimentId}
variantCookieName={variantCookieName}
visitorCookieName={visitorCookieName}
/>
</>
);
}
Pitfalls
- Import map must be regenerated — Run
payload generate:importmapafter adding the plugin. Without it, the Variants sidebar panel will not appear. generatePathreturningnull— Documents wheregeneratePathreturnsnullare silently skipped in the manifest. This is intentional for drafts or incomplete documents.serverURLis required for payloadGlobalAdapter — Middleware reads the manifest via HTTP, so the URL must be reachable from the edge.- Vercel Edge Config needs 4 env vars —
EDGE_CONFIG,EDGE_CONFIG_ID,VERCEL_REST_API_ACCESS_TOKEN, and optionallyVERCEL_TEAM_ID. Missing any one causes adapter initialization failures. - Total traffic percentage must not exceed 100 — The
beforeChangehook validates that the sum of all variant_abPassPercentagevalues stays below 100. The original always gets the remainder. - Variants are hidden from list views — Variant documents (those with
_abVariantOfset) are filtered out of collection list views. They are only accessible from the Variants panel on the original. - Do not set
debug: truein production — This exposes the_abManifestglobal in the admin sidebar, which is intended only for development debugging. - SQL adapters need a migration — After adding the plugin, run
payload migrate:createandpayload migrateto add the new hidden fields to the database.
FAQ
Q: Do I need to add anything to individual collection configs?
A: No. The plugin patches collections automatically based on the slugs provided in the collections config key. No changes to your existing collection definitions are needed.
Q: Can I use the plugin with MongoDB? A: Yes. No migration is needed for MongoDB. SQL adapters (Postgres, SQLite) require running a migration.
Q: How are variants created?
A: Via the Variants panel in the sidebar of any original document. Clicking "Add Variant" calls the POST /_ab/duplicate endpoint, which duplicates the document with a new slug (original--xxxx) and sets initial traffic to 1%.
Q: How does sticky session routing work?
A: On first visit, the middleware assigns a bucket (variant or original) based on weighted random selection and writes a session cookie (payload_ab_bucket_{path}). On subsequent visits, the middleware reads that cookie and routes to the same variant every time.
Q: What cookies does the plugin set?
A: Three cookies: payload_ab_bucket_{path} (session, bucket assignment), ab_visitor_id (365-day, persistent visitor ID for analytics), exp_{path} (90-day, client-readable for analytics adapters).
Q: How does analytics attribution work?
A: Mount <ExperimentTracker> on each variant-served page. It stamps three GA4 event-scoped custom dimensions (fr_ab_experiment, fr_ab_variant, fr_ab_visitor_id) on every subsequent event. Register those dimensions in your GA4 property, then use @focus-reactive/payload-plugin-analytics to view exposure and conversion-rate results per experiment.
Q: What happens if generatePath returns the same path for multiple documents?
A: The last document processed will overwrite earlier entries in the manifest. Ensure generatePath returns unique paths per document.
Further Reading
- Working examples →
./examples.md
Signals
- GitHub stars
- 21
- Last commit
- Sep 2026
ahel review
K1binfo
installs-packages
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
payload-plugin-ab- Source
- github.com/focusreactive/payload-plugins