payload-plugin-ab

SkillFiles & storage

Use 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.

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

OptionTypeDefaultDescription
enabledbooleantrueEnable/disable the plugin entirely
debugbooleanfalseShow the _abManifest global in the admin sidebar
collectionsRecord<string, CollectionABConfig>—Required. Key = collection slug
storageStorageAdapter—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

  1. _abVariants sidebar field — Rendered on original documents only. Shows variant list with per-variant traffic %, an "Add Variant" button, and delete controls.
  2. _abVariantOf relationship field — Read-only, sidebar. Visible on variant documents only; links back to the parent original.
  3. _abPassPercentage hidden number field — Stores traffic % (1–99) for a variant. Managed via the Variants panel; not exposed as a direct form field.
  4. _abVariantPercentages hidden JSON field — Buffers pending % changes on the original; synced to variant docs on save.
  5. Modified slug field — Hidden and read-only on variants (auto-generated as {originalSlug}--{nanoid()}).
  6. POST /_ab/duplicate endpoint — Creates a variant by duplicating the parent, setting a new slug and _abPassPercentage: 1.
  7. Hooks — beforeChange validates % sums; afterChange syncs percentages and recomputes the manifest; afterDelete clears stale manifest entries.
  8. _abManifest global — 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 (original or a variant slug)
  • fr_ab_visitor_id — the ab_visitor_id cookie 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:importmap after adding the plugin. Without it, the Variants sidebar panel will not appear.
  • generatePath returning null — Documents where generatePath returns null are silently skipped in the manifest. This is intentional for drafts or incomplete documents.
  • serverURL is 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 optionally VERCEL_TEAM_ID. Missing any one causes adapter initialization failures.
  • Total traffic percentage must not exceed 100 — The beforeChange hook validates that the sum of all variant _abPassPercentage values stays below 100. The original always gets the remainder.
  • Variants are hidden from list views — Variant documents (those with _abVariantOf set) are filtered out of collection list views. They are only accessible from the Variants panel on the original.
  • Do not set debug: true in production — This exposes the _abManifest global in the admin sidebar, which is intended only for development debugging.
  • SQL adapters need a migration — After adding the plugin, run payload migrate:create and payload migrate to 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