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.

Use payload-plugin-ab in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add payload-plugin-ab and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the payload-plugin-ab skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

payload-plugin-abStart free

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
23
Last commit
Oct 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