npm package: @kiryl.pekarski/payload-plugin-ab
Description: A/B testing plugin for Payload CMS
Author: Kiryl Pekarski
Latest version: 1.3.2

# @kiryl.pekarski/payload-plugin-ab

A/B testing plugin for [Payload CMS](https://payloadcms.com/) v3. Automatically maintains a **variant manifest** — a path-keyed map of A/B variant data — by hooking into Payload collection events. The manifest is designed to be read by Next.js middleware at the edge to route users to the correct variant without an additional database round-trip.

## Table of Contents

- [How It Works](#how-it-works)
- [Key Features](#key-features)
- [Installation](#installation)
- [Quick Start](#quick-start)
  - [Step 1 — Create a variant collection](#step-1--create-a-variant-collection)
  - [Step 2 — Register the plugin](#step-2--register-the-plugin)
  - [Step 3 — Wire up middleware](#step-3--wire-up-middleware)
- [Configuration Reference](#configuration-reference)
  - [Plugin Options](#plugin-options)
  - [CollectionABConfig](#collectionabconfig)
  - [StorageAdapter](#storageadapter)
- [Storage Adapters In Depth](#storage-adapters-in-depth)
  - [payloadGlobalAdapter](#payloadglobaladapter)
  - [vercelEdgeAdapter](#verceledgeadapter)
- [Middleware](#middleware)
  - [createResolveAbRewrite](#createresolveabrewrite)
  - [Weighted Traffic Distribution](#weighted-traffic-distribution)
  - [Cookie System](#cookie-system)
- [Analytics](#analytics)
  - [AnalyticsAdapter Interface](#analyticsadapter-interface)
  - [ABAnalyticsProvider](#abanalyticsprovider)
  - [ExperimentTracker](#experimenttracker)
  - [useABConversion Hook](#useabconversion-hook)
  - [Google Analytics Adapter](#google-analytics-adapter)
- [Multi-Tenant Support](#multi-tenant-support)
- [Localization Support](#localization-support)
- [TypeScript Generics](#typescript-generics)
- [Exports Reference](#exports-reference)

---

## How It Works

The plugin follows a **write-on-change, read-at-edge** pattern:

```
Payload Admin
     │
     │  Editor saves/deletes a variant document
     ▼
beforeChange hook (validates that variant percentage sum ≤ 100)
afterChange / afterDelete hooks (injected by plugin)
     │
     │  Recompute all variants for the affected page path(s)
     ▼
Storage Adapter (write)
  ├─ payloadGlobalAdapter  →  Payload Global (JSON field, read via REST)
  └─ vercelEdgeAdapter     →  Vercel Edge Config (read via @vercel/edge-config)
     │
     ▼
Manifest: { "/about": [variantA, variantB], "/pricing": [...] }
     │
     ▼
Next.js Middleware (edge-compatible read)
     │
     │  storage.read(manifestKey) → VariantData[] | null
     │
     └─ Route user to original page or variant
```

The **manifest** is a plain JSON object:

```ts
type Manifest<TVariantData> = Record<string, TVariantData[]>
// e.g. { "/en/about": [{ bucket: "b", rewritePath: "/en/variants/b/about", passPercentage: 50 }] }
```

A path with no variants is absent from the manifest (no-op for middleware). Only paths that have at least one active variant are written.

---

## Key Features

- **Zero-config hooks** — registers `beforeChange` (percentage validation) and `afterChange`/`afterDelete` hooks on your variant collection automatically; no manual hook wiring required.
- **Weighted traffic distribution** — optionally assign explicit traffic percentages per variant; remaining traffic goes to the original page.
- **Per-path sticky sessions** — bucket assignment is stored in a scoped cookie so returning users always see the same variant.
- **Visitor ID tracking** — a persistent cross-session visitor ID cookie is written for analytics correlation.
- **Locale-aware** — iterates over every configured Payload locale and writes a separate manifest entry per locale.
- **Pluggable storage** — swap between the built-in adapters or implement your own `StorageAdapter`.
- **Edge-compatible reads** — both built-in adapters expose a `read()` method that runs inside Next.js middleware (no Node.js runtime required).
- **Fully typed** — the `TVariantData` generic flows through the entire plugin so your variant data is typed end-to-end.
- **Analytics system** — pluggable `AnalyticsAdapter` interface with a built-in Google Analytics 4 adapter; React components and hooks for impression and conversion tracking.
- **Multi-tenant support** — optional `tenantField` scopes percentage validation to the correct tenant.
- **Debug mode** — optionally expose the manifest Global in the Payload admin panel for inspection.

---

## Installation

```bash
# pnpm (recommended)
pnpm add @kiryl.pekarski/payload-plugin-ab

# npm
npm install @kiryl.pekarski/payload-plugin-ab

# yarn
yarn add @kiryl.pekarski/payload-plugin-ab
```

**If you plan to use the Vercel Edge adapter**, also install its peer dependency:

```bash
pnpm add @vercel/edge-config
```

**Peer dependencies**: `payload ^3.0.0` must already be installed in your project. `next ^14.0.0 || ^15.0.0` and `react ^18.0.0 || ^19.0.0` are optional peer dependencies required only when using the middleware and analytics modules respectively.

---

## Quick Start

This example wires up A/B testing for a `page` collection where variants live in a `page-variants` collection.

### Step 1 — Create a variant collection

The variant collection requires a relationship back to the parent page, a bucket identifier that distinguishes the variant in the URL and cookie, and an optional traffic percentage field:

```ts
// collections/PageVariants.ts
import type { CollectionConfig } from 'payload'

export const PageVariants: CollectionConfig = {
  slug: 'page-variants',
  fields: [
    {
      name: 'page',             // matches the parentField default
      type: 'relationship',
      relationTo: 'page',
      required: true,
      index: true,
    },
    {
      name: 'bucketID',
      type: 'select',
      required: true,
      options: [
        { label: 'A', value: 'a' },
        { label: 'B', value: 'b' },
        { label: 'C', value: 'c' },
      ],
    },
    {
      name: 'passPercentage',   // matches the passPercentageField default
      type: 'number',
      min: 0,
      max: 100,
      admin: {
        description:
          'Percentage of traffic routed to this variant (0–100). ' +
          'All variants for a page combined must not exceed 100%.',
      },
    },
  ],
}
```

Each page can have at most one variant per bucket. The plugin validates that the combined `passPercentage` of all variants for a given page never exceeds 100%.

### Step 2 — Register the plugin

```ts
// payload.config.ts
import { buildConfig } from 'payload'
import { abTestingPlugin } from '@kiryl.pekarski/payload-plugin-ab'
import { payloadGlobalAdapter } from '@kiryl.pekarski/payload-plugin-ab/adapters/payload-global'
import { Page } from './collections/Page'
import { PageVariants } from './collections/PageVariants'

// Shape of data stored per variant in the manifest.
// Your middleware reads this to decide where to rewrite the request.
type ABVariantData = {
  bucket: string          // 'a' | 'b' | 'c'
  rewritePath: string     // the URL this bucket should render
  passPercentage: number  // traffic weight (0–100)
}

const abAdapter = payloadGlobalAdapter<ABVariantData>({
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
})

export default buildConfig({
  collections: [Page, PageVariants],

  plugins: [
    abTestingPlugin<ABVariantData>({
      debug: true,        // set false in production to hide the manifest Global in admin
      storage: abAdapter,
      collections: {
        // Key = parent collection slug
        page: {
          variantCollectionSlug: 'page-variants',
          // parentField: 'page',           // default — name of the relationship field on the variant doc
          // passPercentageField: 'passPercentage', // default

          // Return the URL path used as the manifest key.
          // Return null to skip this document (e.g. pages without a slug).
          generatePath: ({ doc, locale }) => {
            const slug = doc.slug as string | undefined
            if (!slug) return null

            return locale ? `/${locale}/${slug}` : `/${slug}`
          },

          // Return the data stored per variant in the manifest.
          generateVariantData: ({ doc, variantDoc, locale }): ABVariantData => {
            const slug = doc.slug as string
            const prefix = locale ? `/${locale}` : ''

            return {
              bucket: variantDoc.bucketID as string,
              rewritePath: `${prefix}/variants/${variantDoc.bucketID}/${slug}`,
              passPercentage: (variantDoc.passPercentage as number) ?? 0,
            }
          },
        },
      },
    }),
  ],
})
```

### Step 3 — Wire up middleware

Import `createResolveAbRewrite` from the middleware entry point. The factory takes your storage adapter and field accessor functions, and returns a ready-to-use async function.

```ts
// middleware.ts  (Next.js)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createResolveAbRewrite } from '@kiryl.pekarski/payload-plugin-ab/middleware'
import { payloadGlobalAdapter } from '@kiryl.pekarski/payload-plugin-ab/adapters/payload-global'

type ABVariantData = {
  bucket: string
  rewritePath: string
  passPercentage: number
}

const storage = payloadGlobalAdapter<ABVariantData>({
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
})

// Create the resolver once at module level — it is reused across requests
const resolveAbRewrite = createResolveAbRewrite<ABVariantData>({
  storage,
  getBucket: (variant) => variant.bucket,
  getRewritePath: (variant) => variant.rewritePath,
  getPassPercentage: (variant) => variant.passPercentage, // enables weighted routing
})

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  const result = await resolveAbRewrite(
    request,
    pathname, // visiblePathname — used as the per-path cookie key
    pathname, // manifestKey     — adjust if you prepend a locale or other prefix
    pathname, // originalRewritePath — where to send 'original' bucket users
  )

  return result ?? NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next|api|favicon.ico).*)'],
}
```

---

## Configuration Reference

### Plugin Options

```ts
interface AbTestingPluginConfig<TVariantData extends object> {
  /** Enable or disable the plugin entirely. Default: true */
  enabled?: boolean

  /**
   * When true, the manifest Global is visible in the Payload admin panel
   * under the "System" group. Useful for debugging.
   * Default: false
   */
  debug?: boolean

  /**
   * Map of parent collection slug → A/B config for that collection.
   * You can configure multiple parent collections simultaneously.
   */
  collections: Record<string, CollectionABConfig<TVariantData>>

  /** Storage adapter — payloadGlobalAdapter or vercelEdgeAdapter. */
  storage: StorageAdapter<TVariantData>
}
```

### CollectionABConfig

```ts
interface CollectionABConfig<TVariantData extends object> {
  /**
   * Slug of the variant collection (e.g. 'page-variants').
   * Must be registered in your Payload config.
   * Hooks are automatically added to this collection.
   */
  variantCollectionSlug: string

  /**
   * Dot-notation path to the parent relationship field on the variant document.
   * Default: 'page'
   */
  parentField?: string

  /**
   * Dot-notation path to the traffic percentage field on the variant document.
   * Used by the beforeChange validation hook to ensure the combined variant
   * percentages for a page never exceed 100%.
   * Default: 'passPercentage'
   */
  passPercentageField?: string

  /**
   * Optional dot-notation path to the tenant field on the parent document.
   * When set, the percentage-sum validation is scoped per tenant so variants
   * from different tenants don't interfere. See Multi-Tenant Support.
   */
  tenantField?: string

  /**
   * Maps a parent document to the URL path used as the manifest key.
   *
   * Return null to skip writing the manifest for that document
   * (e.g. for drafts, documents without slugs, etc.).
   *
   * Called once per locale when localization is enabled.
   * locale is undefined when Payload localization is not configured.
   */
  generatePath: (args: {
    doc: Record<string, unknown>
    locale: string | undefined
  }) => string | null

  /**
   * Builds the data object stored per variant in the manifest array.
   * This is what your middleware reads — include everything it needs
   * to make a routing decision (bucket, weight, rewrite path, etc.).
   *
   * Called once per variant document, per locale.
   */
  generateVariantData: (args: {
    doc: Record<string, unknown>       // parent document
    variantDoc: Record<string, unknown> // variant document
    locale: string | undefined
  }) => TVariantData
}
```

### StorageAdapter

Both adapters implement the same `StorageAdapter` interface:

```ts
interface StorageAdapter<TVariantData extends object> {
  /** Write variant data for a path. Called from afterChange hooks. */
  write(path: string, variants: TVariantData[], payload: Payload): Promise<void>

  /**
   * Read variant data for a path.
   * Must be Edge-compatible (no Node.js-only APIs).
   * Returns null if the path has no variants.
   */
  read(path: string): Promise<TVariantData[] | null>

  /** Remove all variant data for a path. Called from afterDelete hooks. */
  clear(path: string, payload: Payload): Promise<void>

  /**
   * Optional: return a GlobalConfig to register in Payload.
   * Used by payloadGlobalAdapter to store the manifest.
   * Not needed for vercelEdgeAdapter.
   */
  createGlobal?(debug: boolean): GlobalConfig
}
```

You can implement this interface yourself if neither built-in adapter fits your stack (e.g. Redis, Upstash, Vercel KV, etc.).

---

## Storage Adapters In Depth

### payloadGlobalAdapter

Stores the manifest in a **Payload Global** as a JSON field. The manifest is then fetched from middleware via the Payload REST API.

**Import:**
```ts
import { payloadGlobalAdapter } from '@kiryl.pekarski/payload-plugin-ab/adapters/payload-global'
```

**Options:**

| Option | Type | Default | Description |
|---|---|---|---|
| `globalSlug` | `string` | `'_abManifest'` | Slug for the auto-created Payload Global |
| `serverURL` | `string` | `''` | Full origin of your Payload server (e.g. `https://cms.example.com`). Used by `read()` in middleware. |
| `apiRoute` | `string` | `'/api'` | Payload REST API prefix |

**Usage:**
```ts
import { payloadGlobalAdapter } from '@kiryl.pekarski/payload-plugin-ab/adapters/payload-global'

const storage = payloadGlobalAdapter({
  globalSlug: '_abManifest',          // optional, this is the default
  serverURL: 'https://cms.example.com',
  apiRoute: '/api',                   // optional, this is the default
})
```

**How it reads:** The `read()` method fetches `GET {serverURL}{apiRoute}/globals/{globalSlug}` with `cache: 'no-store'` and returns `data.manifest[path]`. This REST endpoint is public (the Global has `access.read: () => true`).

**When the Global is hidden:** By default the Global is not shown in the Payload admin. Set `debug: true` in the plugin config to make it visible under the **System** group.

---

### vercelEdgeAdapter

Stores the manifest in **Vercel Edge Config** — Vercel's ultra-low-latency global key-value store. Reads are served from the edge with sub-millisecond latency, making this the best option for Vercel-hosted projects.

**Import:**
```ts
import { vercelEdgeAdapter } from '@kiryl.pekarski/payload-plugin-ab/adapters/vercel-edge'
```

**Prerequisites:**

1. Install the Edge Config client:
   ```bash
   pnpm add @vercel/edge-config
   ```

2. Create an Edge Config store in your Vercel project dashboard.

3. Set the following environment variables:

| Variable | Description |
|---|---|
| `EDGE_CONFIG` | Connection string (from Vercel dashboard, e.g. `https://edge-config.vercel.com/ecfg_xxx?token=yyy`) |
| `EDGE_CONFIG_ID` | Edge Config store ID (e.g. `ecfg_xxx`) — passed as `configID` |
| `VERCEL_REST_API_ACCESS_TOKEN` | Vercel REST API token with read/write access — passed as `vercelRestAPIAccessToken` |

**Options:**

| Option | Type | Default | Description |
|---|---|---|---|
| `configID` | `string` | required | Edge Config store ID |
| `configURL` | `string` | required | Full Edge Config URL (same as `EDGE_CONFIG` env var) |
| `vercelRestAPIAccessToken` | `string` | required | Vercel REST API access token |
| `teamID` | `string` | — | Vercel team ID (for team-scoped projects) |
| `manifestKey` | `string` | `'ab-testing'` | Top-level key in Edge Config that holds the manifest object |

**Usage:**
```ts
import { vercelEdgeAdapter } from '@kiryl.pekarski/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, this is the default
})
```

**How it writes:** Updates are made via the Vercel REST API (`PATCH /v1/edge-config/{configID}/items`) using an `upsert` operation. The adapter maintains a local in-memory cache of the manifest to avoid re-reading Edge Config on every write within the same server process.

**How it reads:** Uses `@vercel/edge-config`'s `get(manifestKey)` — this is edge-compatible and extremely fast.

---

## Middleware

### createResolveAbRewrite

The `middleware` entry point exports a factory function `createResolveAbRewrite` that returns the async `resolveAbRewrite` function. Create it once at module level (outside the middleware handler) so the storage adapter is shared across requests.

**Import:**
```ts
import { createResolveAbRewrite } from '@kiryl.pekarski/payload-plugin-ab/middleware'
```

**Factory config — `ResolveAbRewriteConfig<TVariantData>`:**

| Field | Type | Required | Description |
|---|---|---|---|
| `storage` | `StorageAdapter<TVariantData>` | ✓ | Same adapter passed to the plugin. |
| `getBucket` | `(variant: TVariantData) => string` | ✓ | Extracts the bucket string from a variant record. |
| `getRewritePath` | `(variant: TVariantData) => string` | ✓ | Extracts the Next.js internal rewrite path from a variant record. |
| `getPassPercentage` | `(variant: TVariantData) => number` | — | Extracts the traffic percentage (0–100). When provided, enables weighted routing. When omitted, all variants and 'original' share equal probability. |
| `cookies` | `ResolveAbRewriteCookieConfig` | — | Cookie name and TTL overrides. See [Cookie System](#cookie-system). |

**Returned function signature:**
```ts
resolveAbRewrite(
  request: NextRequest,
  visiblePathname: string,     // URL the user sees — used as bucket cookie key
  manifestKey: string,         // key to look up in the manifest
  originalRewritePath: string  // internal path for 'original' bucket users
): Promise<NextResponse | null>
```

Returns a `NextResponse` with rewrites and cookies set, or `null` if no variant applies (the caller falls through to `NextResponse.next()`).

**Key behaviours:**

| Behaviour | Detail |
|---|---|
| **Sticky sessions** | Bucket is stored in a per-path cookie so the same user always sees the same variant. |
| **Weighted routing** | When `getPassPercentage` is provided, variants are selected proportionally. Remaining traffic (100 − sum) goes to 'original'. |
| **Uniform routing** | When `getPassPercentage` is omitted, each variant and 'original' share equal (1/(n+1)) probability. |
| **Visitor ID** | A persistent visitor ID (`ab_visitor_id`) is generated on first visit and stored in a long-lived cookie for analytics use. |
| **Experiment cookie** | A per-experiment cookie records the assigned bucket so analytics hooks can read it client-side. |
| **Error safety** | `storage.read()` is wrapped in try/catch; any adapter error results in `null` (pass through). |

**Full usage example:**
```ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createResolveAbRewrite } from '@kiryl.pekarski/payload-plugin-ab/middleware'
import { payloadGlobalAdapter } from '@kiryl.pekarski/payload-plugin-ab/adapters/payload-global'

type ABVariantData = {
  bucket: string
  rewritePath: string
  passPercentage: number
}

const storage = payloadGlobalAdapter<ABVariantData>({
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
})

const resolveAbRewrite = createResolveAbRewrite<ABVariantData>({
  storage,
  getBucket: (v) => v.bucket,
  getRewritePath: (v) => v.rewritePath,
  getPassPercentage: (v) => v.passPercentage,
  cookies: {
    bucketCookiePrefix: 'payload_ab_bucket', // default
    visitorIdCookieName: 'ab_visitor_id',    // default
    visitorIdMaxAge: 60 * 60 * 24 * 365,    // 1 year (default)
    expCookieMaxAge: 60 * 60 * 24 * 90,     // 90 days (default)
  },
})

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  const result = await resolveAbRewrite(
    request,
    pathname, // visiblePathname
    pathname, // manifestKey
    pathname, // originalRewritePath
  )

  return result ?? NextResponse.next()
}
```

The three separate path arguments let you decouple what the user sees, the manifest lookup key, and the internal rewrite target — useful with locale prefixes or custom rewrite rules:

```ts
// Example: locale-prefixed paths where the manifest was written with a locale prefix
const internalPath = `/en/pages${pathname}`

await resolveAbRewrite(
  request,
  pathname,     // what the user sees (e.g. '/about')
  internalPath, // manifest lookup key (e.g. '/en/pages/about')
  internalPath, // rewrite for 'original' bucket
)
```

---

### Weighted Traffic Distribution

When `getPassPercentage` is supplied to `createResolveAbRewrite`, variant selection is weighted:

- Each variant receives exactly its `passPercentage`% of traffic.
- The original page receives `100 − sum(passPercentage)` percent.
- If the sum of all variant percentages equals 100, no traffic reaches the original page.

The plugin validates this on save via a `beforeChange` hook. Attempting to create or update a variant that would push the total above 100% raises a Payload `ValidationError` with a descriptive message showing the remaining available percentage.

```ts
// Example: Variant A = 30%, Variant B = 50%, Original = 20%
type ABVariantData = {
  bucket: string
  rewritePath: string
  passPercentage: number
}

const resolveAbRewrite = createResolveAbRewrite<ABVariantData>({
  storage,
  getBucket: (v) => v.bucket,
  getRewritePath: (v) => v.rewritePath,
  getPassPercentage: (v) => v.passPercentage,
})
```

To enable validation in the Payload plugin, the `passPercentageField` option on `CollectionABConfig` must point to the field that stores the percentage. It defaults to `'passPercentage'` — omit it if you use the default field name.

---

### Cookie System

The middleware writes up to three cookies on first assignment:

| Cookie | Default name | Lifetime | Purpose |
|---|---|---|---|
| Bucket cookie | `payload_ab_bucket_{manifestKey}` | Session (no maxAge) | Records which bucket this user is assigned to. Keys off `manifestKey` with slashes replaced by underscores. Ensures sticky sessions. |
| Visitor ID cookie | `ab_visitor_id` | 365 days | Persistent cross-session visitor identifier. Used for analytics. |
| Experiment cookie | `exp_{encodeURIComponent(manifestKey)}` | 90 days | Records the assigned bucket in a named cookie. Analytics hooks read this client-side. |

**Sharing cookie config between middleware and analytics:**

Define an `AbCookieConfig` object once and pass it to both `createResolveAbRewrite` and the analytics utilities to keep cookie names in sync automatically:

```ts
// lib/abCookies.ts
import type { AbCookieConfig } from '@kiryl.pekarski/payload-plugin-ab/middleware'

export const abCookies: AbCookieConfig = {
  visitorIdCookieName: 'my_visitor_id',        // override visitor ID cookie name
  getExpCookieName: (key) => `ab_exp_${key}`,  // override experiment cookie name
}
```

```ts
// middleware.ts
import { abCookies } from './lib/abCookies'

const resolveAbRewrite = createResolveAbRewrite({
  storage,
  getBucket: ...,
  getRewritePath: ...,
  cookies: abCookies, // pass the shared config
})
```

**`resolveAbCookieNames(config, experimentId)`:**

Use this utility in Server Components to derive cookie names as plain strings that can be safely passed as props to Client Components:

```ts
// app/[locale]/[slug]/page.tsx (Server Component)
import { resolveAbCookieNames } from '@kiryl.pekarski/payload-plugin-ab/middleware'
import { abCookies } from '@/lib/abCookies'

export default async function Page({ params }) {
  const experimentId = `/${params.locale}/${params.slug}`
  const { variantCookieName, visitorCookieName } = resolveAbCookieNames(
    abCookies,
    experimentId,
  )

  return (
    <ExperimentTracker
      experimentId={experimentId}
      variantCookieName={variantCookieName}
      visitorCookieName={visitorCookieName}
    />
  )
}
```

| Parameter | Type | Description |
|---|---|---|
| `config` | `AbCookieConfig \| undefined` | Cookie config (or `undefined` to use all defaults) |
| `experimentId` | `string` | The experiment/manifest key (e.g. `'/en/about'`) |

Returns `{ variantCookieName: string, visitorCookieName: string }`.

---

## Analytics

The analytics system is a pluggable, adapter-based API. Install once at app root, then use the provided React components and hooks throughout your pages.

### AnalyticsAdapter Interface

```ts
interface AnalyticsAdapter {
  /** Fire when a user is assigned to and shown a variant (client-side). */
  trackImpression(args: TrackImpressionArgs): void

  /** Fire when a user completes a conversion goal (client-side). */
  trackConversion(args: TrackConversionArgs): void

  /** Optional: fire an impression server-side (RSC / Server Action / middleware). */
  trackImpressionServer?(args: TrackImpressionArgs): Promise<void>

  /** Optional: fetch aggregated stats for an experiment. */
  getStats?(experimentId: string, dateRange?: DateRange): Promise<ExperimentStats>
}

interface TrackImpressionArgs {
  experimentId: string    // e.g. "/en/about"
  variantBucket: string   // e.g. "a" | "b" | "original"
  visitorId: string
  locale?: string
  metadata?: Record<string, string | number | boolean>
}

interface TrackConversionArgs {
  experimentId: string
  variantBucket: string
  visitorId: string
  goalId: string          // e.g. "cta_click", "purchase"
  goalValue?: number
  locale?: string
  metadata?: Record<string, string | number | boolean>
}
```

You can implement `AnalyticsAdapter` yourself to connect any analytics backend.

### ABAnalyticsProvider

Wrap your app (or a page subtree) with `ABAnalyticsProvider` to make the analytics adapter available to all child components via React context:

```tsx
// app/layout.tsx
import { ABAnalyticsProvider } from '@kiryl.pekarski/payload-plugin-ab/analytics/client'
import { googleAnalyticsAdapter } from '@kiryl.pekarski/payload-plugin-ab/analytics/adapters/google-analytics'

const analyticsAdapter = googleAnalyticsAdapter({
  measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID!,
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ABAnalyticsProvider adapter={analyticsAdapter}>
          {children}
        </ABAnalyticsProvider>
      </body>
    </html>
  )
}
```

### ExperimentTracker

A zero-render Client Component that fires a **single impression event per browser session** when it mounts. Drop it anywhere inside a variant-aware page — it reads the experiment and visitor cookies automatically.

```tsx
// app/[locale]/[slug]/page.tsx  (Server Component)
import { ExperimentTracker } from '@kiryl.pekarski/payload-plugin-ab/analytics/client'
import { resolveAbCookieNames } from '@kiryl.pekarski/payload-plugin-ab/middleware'
import { abCookies } from '@/lib/abCookies'

export default async function Page({ params }) {
  const experimentId = `/${params.locale}/${params.slug}`
  const { variantCookieName, visitorCookieName } = resolveAbCookieNames(
    abCookies,
    experimentId,
  )

  return (
    <>
      {/* page content */}
      <ExperimentTracker
        experimentId={experimentId}
        variantCookieName={variantCookieName}
        visitorCookieName={visitorCookieName}
      />
    </>
  )
}
```

**`ExperimentTracker` props:**

| Prop | Type | Default | Description |
|---|---|---|---|
| `experimentId` | `string` | required | The experiment identifier (manifest key). |
| `variantCookieName` | `string` | `exp_${experimentId}` | Cookie holding the assigned bucket. Use `resolveAbCookieNames` to derive from a shared config. |
| `visitorCookieName` | `string` | `'ab_visitor_id'` | Cookie holding the visitor ID. |

Impressions are deduplicated with `sessionStorage` — one event per experiment per browser session regardless of re-renders.

### useABConversion Hook

Use this hook to track conversion goals. Call the returned function from any user interaction handler:

```tsx
'use client'

import { useABConversion } from '@kiryl.pekarski/payload-plugin-ab/analytics/client'

// experimentId and cookie names are resolved in a Server Component parent
// and passed down as props
export function CTAButton({
  experimentId,
  variantCookieName,
  visitorCookieName,
}: {
  experimentId: string
  variantCookieName: string
  visitorCookieName: string
}) {
  const trackConversion = useABConversion({
    experimentId,
    variantCookieName,
    visitorCookieName,
  })

  return (
    <button onClick={() => trackConversion({ goalId: 'cta_click', goalValue: 1 })}>
      Get Started
    </button>
  )
}
```

**`useABConversion` options:**

| Option | Type | Default | Description |
|---|---|---|---|
| `experimentId` | `string` | required | The experiment identifier. |
| `variantCookieName` | `string` | `exp_${experimentId}` | Cookie holding the assigned bucket. |
| `visitorCookieName` | `string` | `'ab_visitor_id'` | Cookie holding the visitor ID. |

Returns `TrackConversionFn`: `(args: { goalId: string; goalValue?: number }) => void`.

The hook reads both cookies client-side. If either cookie is missing (user not assigned to any experiment), the call is a no-op.

### Google Analytics Adapter

The built-in GA4 adapter handles client-side tracking via `gtag`, optional server-side tracking via the GA4 Measurement Protocol, and optional stats retrieval via the GA4 Data API.

**Import:**
```ts
import { googleAnalyticsAdapter } from '@kiryl.pekarski/payload-plugin-ab/analytics/adapters/google-analytics'
```

**Config — `GoogleAnalyticsAdapterConfig`:**

| Option | Type | Required | Description |
|---|---|---|---|
| `measurementId` | `string` | ✓ | GA4 Measurement ID, e.g. `'G-XXXXXXXXXX'` |
| `apiSecret` | `string` | — | GA4 Measurement Protocol API secret. Enables `trackImpressionServer()`. |
| `propertyId` | `string` | — | GA4 property resource name, e.g. `'properties/123456789'`. Required for `getStats()`. |
| `getAccessToken` | `() => Promise<string>` | — | Returns a valid OAuth2 access token for the GA4 Data API (scope: `analytics.readonly`). Required for `getStats()`. |
| `impressionEventName` | `string` | — | Custom GA4 event name for impressions. Default: `'ab_impression'` |
| `conversionEventName` | `string` | — | Custom GA4 event name for conversions. Default: `'ab_conversion'` |

**Full setup example:**
```ts
import { googleAnalyticsAdapter } from '@kiryl.pekarski/payload-plugin-ab/analytics/adapters/google-analytics'
import { GoogleAuth } from 'google-auth-library'

const auth = new GoogleAuth({
  scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
})

const analyticsAdapter = googleAnalyticsAdapter({
  measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID!,
  apiSecret: process.env.GA4_API_SECRET,       // enables server-side impression tracking
  propertyId: process.env.GA4_PROPERTY_ID,     // enables getStats()
  getAccessToken: () => auth.getAccessToken(), // enables getStats()
})
```

**GA4 event parameters sent:**

*Impression (`ab_impression` by default):*
- `experiment_id` — manifest key / experiment ID
- `variant_bucket` — assigned bucket (`'a'`, `'b'`, `'original'`, etc.)
- `visitor_id` — persistent visitor identifier
- `locale` — if provided
- any extra `metadata` key/value pairs

*Conversion (`ab_conversion` by default):*
- all impression parameters, plus:
- `goal_id` — conversion goal identifier
- `value` — numeric goal value (if provided)

---

## Multi-Tenant Support

For multi-tenant Payload setups, the percentage-sum validation can be scoped per tenant so variants from different tenants don't interfere with each other.

Add `tenantField` to your `CollectionABConfig`:

```ts
abTestingPlugin<ABVariantData>({
  storage,
  collections: {
    page: {
      variantCollectionSlug: 'page-variants',
      tenantField: 'tenant', // dot-notation path to the tenant field on the parent document
      generatePath: ...,
      generateVariantData: ...,
    },
  },
})
```

When `tenantField` is set, the `validateVariantPercentageSum` hook looks up the parent document to find its tenant ID, then filters sibling variants to only those sharing the same tenant before computing the percentage sum.

---

## Localization Support

If your Payload config has `localization` enabled, the plugin automatically handles it. For every locale defined in `payload.config.localization.locales`, the hooks:

1. Fetch the parent document in that locale.
2. Call `generatePath({ doc, locale })` to determine the manifest key.
3. Call `generateVariantData({ doc, variantDoc, locale })` for each variant.
4. Write a separate manifest entry per locale.

This means you can generate locale-prefixed paths from any field on the document:

```ts
generatePath: ({ doc, locale }) => {
  const slug = doc.slug as string | undefined
  if (!slug) return null

  return locale ? `/${locale}/${slug}` : `/${slug}` // e.g. '/en/about', '/fr/about'
},
```

No additional configuration is needed — locale support is automatic when `payload.config.localization` is set. Note that `locale` is `string | undefined`; it is `undefined` when Payload localization is not configured.

---

## TypeScript Generics

The plugin is fully generic over `TVariantData extends object`. Define your variant shape once and it flows through the entire stack:

```ts
// Define your variant shape — include everything middleware needs to route the request
type ABVariantData = {
  bucket: string          // which bucket ('a' | 'b' | 'c')
  rewritePath: string     // internal URL middleware rewrites to
  passPercentage: number  // traffic weight for weighted routing
}

// 1. Pass the type to the storage adapter
const storage = payloadGlobalAdapter<ABVariantData>({ serverURL: '...' })

// 2. Pass the same type to the plugin
abTestingPlugin<ABVariantData>({
  storage,
  collections: {
    page: {
      variantCollectionSlug: 'page-variants',
      generatePath: ({ doc, locale }) => {
        const slug = doc.slug as string | undefined
        return slug ? (locale ? `/${locale}/${slug}` : `/${slug}`) : null
      },
      // variantDoc is Record<string, unknown> — cast fields as needed
      generateVariantData: ({ doc, variantDoc, locale }): ABVariantData => ({
        bucket: variantDoc.bucketID as string,
        rewritePath: locale
          ? `/${locale}/variants/${variantDoc.bucketID}/${doc.slug}`
          : `/variants/${variantDoc.bucketID}/${doc.slug}`,
        passPercentage: (variantDoc.passPercentage as number) ?? 0,
      }),
    },
  },
})

// 3. Pass the same type to the middleware factory
const resolveAbRewrite = createResolveAbRewrite<ABVariantData>({
  storage,
  getBucket: (v) => v.bucket,            // v is fully typed as ABVariantData
  getRewritePath: (v) => v.rewritePath,
  getPassPercentage: (v) => v.passPercentage,
})

// In middleware: storage.read(path) returns ABVariantData[] | null — fully typed
```

---

## Exports Reference

| Import path | Exports |
|---|---|
| `@kiryl.pekarski/payload-plugin-ab` | `abTestingPlugin`, types: `AbTestingPluginConfig`, `CollectionABConfig`, `StorageAdapter`, `AbCookieConfig`, `resolveAbCookieNames`, `ResolvedAbCookieNames` |
| `@kiryl.pekarski/payload-plugin-ab/adapters/payload-global` | `payloadGlobalAdapter` |
| `@kiryl.pekarski/payload-plugin-ab/adapters/vercel-edge` | `vercelEdgeAdapter` |
| `@kiryl.pekarski/payload-plugin-ab/middleware` | `createResolveAbRewrite`, types: `ResolveAbRewriteConfig`, `ResolveAbRewriteCookieConfig`, `AbCookieConfig`, `resolveAbCookieNames`, `ResolvedAbCookieNames` |
| `@kiryl.pekarski/payload-plugin-ab/analytics` | Types only: `AnalyticsAdapter`, `TrackImpressionArgs`, `TrackConversionArgs`, `ExperimentStats`, `VariantStats`, `DateRange`, `AbCookieConfig`, `resolveAbCookieNames`, `ResolvedAbCookieNames` |
| `@kiryl.pekarski/payload-plugin-ab/analytics/client` | `ABAnalyticsProvider`, `ExperimentTracker`, `useABConversion`, types: `ExperimentTrackerProps`, `UseABConversionOptions`, `TrackConversionFn`, `AbCookieConfig`, `resolveAbCookieNames`, `ResolvedAbCookieNames` |
| `@kiryl.pekarski/payload-plugin-ab/analytics/adapters/google-analytics` | `googleAnalyticsAdapter`, `GoogleAnalyticsAdapterConfig` |