@focus-reactive/payload-plugin-ab 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1009 @@
1
+ # @focus-reactive/payload-plugin-ab
2
+
3
+ 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.
4
+
5
+ ## Table of Contents
6
+
7
+ - [How It Works](#how-it-works)
8
+ - [Key Features](#key-features)
9
+ - [Installation](#installation)
10
+ - [Quick Start](#quick-start)
11
+ - [Step 1 — Create a variant collection](#step-1--create-a-variant-collection)
12
+ - [Step 2 — Register the plugin](#step-2--register-the-plugin)
13
+ - [Step 3 — Wire up middleware](#step-3--wire-up-middleware)
14
+ - [Configuration Reference](#configuration-reference)
15
+ - [Plugin Options](#plugin-options)
16
+ - [CollectionABConfig](#collectionabconfig)
17
+ - [StorageAdapter](#storageadapter)
18
+ - [Storage Adapters In Depth](#storage-adapters-in-depth)
19
+ - [payloadGlobalAdapter](#payloadglobaladapter)
20
+ - [vercelEdgeAdapter](#verceledgeadapter)
21
+ - [Middleware](#middleware)
22
+ - [createResolveAbRewrite](#createresolveabrewrite)
23
+ - [Weighted Traffic Distribution](#weighted-traffic-distribution)
24
+ - [Cookie System](#cookie-system)
25
+ - [Analytics](#analytics)
26
+ - [AnalyticsAdapter Interface](#analyticsadapter-interface)
27
+ - [ABAnalyticsProvider](#abanalyticsprovider)
28
+ - [ExperimentTracker](#experimenttracker)
29
+ - [useABConversion Hook](#useabconversion-hook)
30
+ - [Google Analytics Adapter](#google-analytics-adapter)
31
+ - [Multi-Tenant Support](#multi-tenant-support)
32
+ - [Localization Support](#localization-support)
33
+ - [TypeScript Generics](#typescript-generics)
34
+ - [Exports Reference](#exports-reference)
35
+
36
+ ---
37
+
38
+ ## How It Works
39
+
40
+ The plugin follows a **write-on-change, read-at-edge** pattern:
41
+
42
+ ```
43
+ Payload Admin
44
+
45
+ │ Editor saves/deletes a variant document
46
+
47
+ beforeChange hook (validates that variant percentage sum ≤ 100)
48
+ afterChange / afterDelete hooks (injected by plugin)
49
+
50
+ │ Recompute all variants for the affected page path(s)
51
+
52
+ Storage Adapter (write)
53
+ ├─ payloadGlobalAdapter → Payload Global (JSON field, read via REST)
54
+ └─ vercelEdgeAdapter → Vercel Edge Config (read via @vercel/edge-config)
55
+
56
+
57
+ Manifest: { "/about": [variantA, variantB], "/pricing": [...] }
58
+
59
+
60
+ Next.js Middleware (edge-compatible read)
61
+
62
+ │ storage.read(manifestKey) → VariantData[] | null
63
+
64
+ └─ Route user to original page or variant
65
+ ```
66
+
67
+ The **manifest** is a plain JSON object:
68
+
69
+ ```ts
70
+ type Manifest<TVariantData> = Record<string, TVariantData[]>
71
+ // e.g. { "/en/about": [{ bucket: "b", rewritePath: "/en/variants/b/about", passPercentage: 50 }] }
72
+ ```
73
+
74
+ 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.
75
+
76
+ ---
77
+
78
+ ## Key Features
79
+
80
+ - **Zero-config hooks** — registers `beforeChange` (percentage validation) and `afterChange`/`afterDelete` hooks on your variant collection automatically; no manual hook wiring required.
81
+ - **Weighted traffic distribution** — optionally assign explicit traffic percentages per variant; remaining traffic goes to the original page.
82
+ - **Per-path sticky sessions** — bucket assignment is stored in a scoped cookie so returning users always see the same variant.
83
+ - **Visitor ID tracking** — a persistent cross-session visitor ID cookie is written for analytics correlation.
84
+ - **Locale-aware** — iterates over every configured Payload locale and writes a separate manifest entry per locale.
85
+ - **Pluggable storage** — swap between the built-in adapters or implement your own `StorageAdapter`.
86
+ - **Edge-compatible reads** — both built-in adapters expose a `read()` method that runs inside Next.js middleware (no Node.js runtime required).
87
+ - **Fully typed** — the `TVariantData` generic flows through the entire plugin so your variant data is typed end-to-end.
88
+ - **Analytics system** — pluggable `AnalyticsAdapter` interface with a built-in Google Analytics 4 adapter; React components and hooks for impression and conversion tracking.
89
+ - **Multi-tenant support** — optional `tenantField` scopes percentage validation to the correct tenant.
90
+ - **Debug mode** — optionally expose the manifest Global in the Payload admin panel for inspection.
91
+
92
+ ---
93
+
94
+ ## Installation
95
+
96
+ ```bash
97
+ # pnpm (recommended)
98
+ pnpm add @focus-reactive/payload-plugin-ab
99
+
100
+ # npm
101
+ npm install @focus-reactive/payload-plugin-ab
102
+
103
+ # yarn
104
+ yarn add @focus-reactive/payload-plugin-ab
105
+ ```
106
+
107
+ **If you plan to use the Vercel Edge adapter**, also install its peer dependency:
108
+
109
+ ```bash
110
+ pnpm add @vercel/edge-config
111
+ ```
112
+
113
+ **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.
114
+
115
+ ---
116
+
117
+ ## Quick Start
118
+
119
+ This example wires up A/B testing for a `page` collection where variants live in a `page-variants` collection.
120
+
121
+ ### Step 1 — Create a variant collection
122
+
123
+ 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:
124
+
125
+ ```ts
126
+ // collections/PageVariants.ts
127
+ import type { CollectionConfig } from 'payload'
128
+
129
+ export const PageVariants: CollectionConfig = {
130
+ slug: 'page-variants',
131
+ fields: [
132
+ {
133
+ name: 'page', // matches the parentField default
134
+ type: 'relationship',
135
+ relationTo: 'page',
136
+ required: true,
137
+ index: true,
138
+ },
139
+ {
140
+ name: 'bucketID',
141
+ type: 'select',
142
+ required: true,
143
+ options: [
144
+ { label: 'A', value: 'a' },
145
+ { label: 'B', value: 'b' },
146
+ { label: 'C', value: 'c' },
147
+ ],
148
+ },
149
+ {
150
+ name: 'passPercentage', // matches the passPercentageField default
151
+ type: 'number',
152
+ min: 0,
153
+ max: 100,
154
+ admin: {
155
+ description:
156
+ 'Percentage of traffic routed to this variant (0–100). ' +
157
+ 'All variants for a page combined must not exceed 100%.',
158
+ },
159
+ },
160
+ ],
161
+ }
162
+ ```
163
+
164
+ 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%.
165
+
166
+ ### Step 2 — Register the plugin
167
+
168
+ ```ts
169
+ // payload.config.ts
170
+ import { buildConfig } from 'payload'
171
+ import { abTestingPlugin } from '@focus-reactive/payload-plugin-ab'
172
+ import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
173
+ import { Page } from './collections/Page'
174
+ import { PageVariants } from './collections/PageVariants'
175
+
176
+ // Shape of data stored per variant in the manifest.
177
+ // Your middleware reads this to decide where to rewrite the request.
178
+ type ABVariantData = {
179
+ bucket: string // 'a' | 'b' | 'c'
180
+ rewritePath: string // the URL this bucket should render
181
+ passPercentage: number // traffic weight (0–100)
182
+ }
183
+
184
+ const abAdapter = payloadGlobalAdapter<ABVariantData>({
185
+ serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
186
+ })
187
+
188
+ export default buildConfig({
189
+ collections: [Page, PageVariants],
190
+
191
+ plugins: [
192
+ abTestingPlugin<ABVariantData>({
193
+ debug: true, // set false in production to hide the manifest Global in admin
194
+ storage: abAdapter,
195
+ collections: {
196
+ // Key = parent collection slug
197
+ page: {
198
+ variantCollectionSlug: 'page-variants',
199
+ // parentField: 'page', // default — name of the relationship field on the variant doc
200
+ // passPercentageField: 'passPercentage', // default
201
+
202
+ // Return the URL path used as the manifest key.
203
+ // Return null to skip this document (e.g. pages without a slug).
204
+ generatePath: ({ doc, locale }) => {
205
+ const slug = doc.slug as string | undefined
206
+ if (!slug) return null
207
+
208
+ return locale ? `/${locale}/${slug}` : `/${slug}`
209
+ },
210
+
211
+ // Return the data stored per variant in the manifest.
212
+ generateVariantData: ({ doc, variantDoc, locale }): ABVariantData => {
213
+ const slug = doc.slug as string
214
+ const prefix = locale ? `/${locale}` : ''
215
+
216
+ return {
217
+ bucket: variantDoc.bucketID as string,
218
+ rewritePath: `${prefix}/variants/${variantDoc.bucketID}/${slug}`,
219
+ passPercentage: (variantDoc.passPercentage as number) ?? 0,
220
+ }
221
+ },
222
+ },
223
+ },
224
+ }),
225
+ ],
226
+ })
227
+ ```
228
+
229
+ ### Step 3 — Wire up middleware
230
+
231
+ 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.
232
+
233
+ ```ts
234
+ // middleware.ts (Next.js)
235
+ import { NextResponse } from 'next/server'
236
+ import type { NextRequest } from 'next/server'
237
+ import { createResolveAbRewrite } from '@focus-reactive/payload-plugin-ab/middleware'
238
+ import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
239
+
240
+ type ABVariantData = {
241
+ bucket: string
242
+ rewritePath: string
243
+ passPercentage: number
244
+ }
245
+
246
+ const storage = payloadGlobalAdapter<ABVariantData>({
247
+ serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
248
+ })
249
+
250
+ // Create the resolver once at module level — it is reused across requests
251
+ const resolveAbRewrite = createResolveAbRewrite<ABVariantData>({
252
+ storage,
253
+ getBucket: (variant) => variant.bucket,
254
+ getRewritePath: (variant) => variant.rewritePath,
255
+ getPassPercentage: (variant) => variant.passPercentage, // enables weighted routing
256
+ })
257
+
258
+ export async function middleware(request: NextRequest) {
259
+ const { pathname } = request.nextUrl
260
+
261
+ const result = await resolveAbRewrite(
262
+ request,
263
+ pathname, // visiblePathname — used as the per-path cookie key
264
+ pathname, // manifestKey — adjust if you prepend a locale or other prefix
265
+ pathname, // originalRewritePath — where to send 'original' bucket users
266
+ )
267
+
268
+ return result ?? NextResponse.next()
269
+ }
270
+
271
+ export const config = {
272
+ matcher: ['/((?!_next|api|favicon.ico).*)'],
273
+ }
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Configuration Reference
279
+
280
+ ### Plugin Options
281
+
282
+ ```ts
283
+ interface AbTestingPluginConfig<TVariantData extends object> {
284
+ /** Enable or disable the plugin entirely. Default: true */
285
+ enabled?: boolean
286
+
287
+ /**
288
+ * When true, the manifest Global is visible in the Payload admin panel
289
+ * under the "System" group. Useful for debugging.
290
+ * Default: false
291
+ */
292
+ debug?: boolean
293
+
294
+ /**
295
+ * Map of parent collection slug → A/B config for that collection.
296
+ * You can configure multiple parent collections simultaneously.
297
+ */
298
+ collections: Record<string, CollectionABConfig<TVariantData>>
299
+
300
+ /** Storage adapter — payloadGlobalAdapter or vercelEdgeAdapter. */
301
+ storage: StorageAdapter<TVariantData>
302
+ }
303
+ ```
304
+
305
+ ### CollectionABConfig
306
+
307
+ ```ts
308
+ interface CollectionABConfig<TVariantData extends object> {
309
+ /**
310
+ * Slug of the variant collection (e.g. 'page-variants').
311
+ * Must be registered in your Payload config.
312
+ * Hooks are automatically added to this collection.
313
+ */
314
+ variantCollectionSlug: string
315
+
316
+ /**
317
+ * Dot-notation path to the parent relationship field on the variant document.
318
+ * Default: 'page'
319
+ */
320
+ parentField?: string
321
+
322
+ /**
323
+ * Dot-notation path to the traffic percentage field on the variant document.
324
+ * Used by the beforeChange validation hook to ensure the combined variant
325
+ * percentages for a page never exceed 100%.
326
+ * Default: 'passPercentage'
327
+ */
328
+ passPercentageField?: string
329
+
330
+ /**
331
+ * Optional dot-notation path to the tenant field on the parent document.
332
+ * When set, the percentage-sum validation is scoped per tenant so variants
333
+ * from different tenants don't interfere. See Multi-Tenant Support.
334
+ */
335
+ tenantField?: string
336
+
337
+ /**
338
+ * Maps a parent document to the URL path used as the manifest key.
339
+ *
340
+ * Return null to skip writing the manifest for that document
341
+ * (e.g. for drafts, documents without slugs, etc.).
342
+ *
343
+ * Called once per locale when localization is enabled.
344
+ * locale is undefined when Payload localization is not configured.
345
+ */
346
+ generatePath: (args: {
347
+ doc: Record<string, unknown>
348
+ locale: string | undefined
349
+ }) => string | null
350
+
351
+ /**
352
+ * Builds the data object stored per variant in the manifest array.
353
+ * This is what your middleware reads — include everything it needs
354
+ * to make a routing decision (bucket, weight, rewrite path, etc.).
355
+ *
356
+ * Called once per variant document, per locale.
357
+ */
358
+ generateVariantData: (args: {
359
+ doc: Record<string, unknown> // parent document
360
+ variantDoc: Record<string, unknown> // variant document
361
+ locale: string | undefined
362
+ }) => TVariantData
363
+ }
364
+ ```
365
+
366
+ ### StorageAdapter
367
+
368
+ Both adapters implement the same `StorageAdapter` interface:
369
+
370
+ ```ts
371
+ interface StorageAdapter<TVariantData extends object> {
372
+ /** Write variant data for a path. Called from afterChange hooks. */
373
+ write(path: string, variants: TVariantData[], payload: Payload): Promise<void>
374
+
375
+ /**
376
+ * Read variant data for a path.
377
+ * Must be Edge-compatible (no Node.js-only APIs).
378
+ * Returns null if the path has no variants.
379
+ */
380
+ read(path: string): Promise<TVariantData[] | null>
381
+
382
+ /** Remove all variant data for a path. Called from afterDelete hooks. */
383
+ clear(path: string, payload: Payload): Promise<void>
384
+
385
+ /**
386
+ * Optional: return a GlobalConfig to register in Payload.
387
+ * Used by payloadGlobalAdapter to store the manifest.
388
+ * Not needed for vercelEdgeAdapter.
389
+ */
390
+ createGlobal?(debug: boolean): GlobalConfig
391
+ }
392
+ ```
393
+
394
+ You can implement this interface yourself if neither built-in adapter fits your stack (e.g. Redis, Upstash, Vercel KV, etc.).
395
+
396
+ ---
397
+
398
+ ## Storage Adapters In Depth
399
+
400
+ ### payloadGlobalAdapter
401
+
402
+ Stores the manifest in a **Payload Global** as a JSON field. The manifest is then fetched from middleware via the Payload REST API.
403
+
404
+ **Import:**
405
+ ```ts
406
+ import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
407
+ ```
408
+
409
+ **Options:**
410
+
411
+ | Option | Type | Default | Description |
412
+ |---|---|---|---|
413
+ | `globalSlug` | `string` | `'_abManifest'` | Slug for the auto-created Payload Global |
414
+ | `serverURL` | `string` | `''` | Full origin of your Payload server (e.g. `https://cms.example.com`). Used by `read()` in middleware. |
415
+ | `apiRoute` | `string` | `'/api'` | Payload REST API prefix |
416
+
417
+ **Usage:**
418
+ ```ts
419
+ import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
420
+
421
+ const storage = payloadGlobalAdapter({
422
+ globalSlug: '_abManifest', // optional, this is the default
423
+ serverURL: 'https://cms.example.com',
424
+ apiRoute: '/api', // optional, this is the default
425
+ })
426
+ ```
427
+
428
+ **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`).
429
+
430
+ **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.
431
+
432
+ ---
433
+
434
+ ### vercelEdgeAdapter
435
+
436
+ 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.
437
+
438
+ **Import:**
439
+ ```ts
440
+ import { vercelEdgeAdapter } from '@focus-reactive/payload-plugin-ab/adapters/vercel-edge'
441
+ ```
442
+
443
+ **Prerequisites:**
444
+
445
+ 1. Install the Edge Config client:
446
+ ```bash
447
+ pnpm add @vercel/edge-config
448
+ ```
449
+
450
+ 2. Create an Edge Config store in your Vercel project dashboard.
451
+
452
+ 3. Set the following environment variables:
453
+
454
+ | Variable | Description |
455
+ |---|---|
456
+ | `EDGE_CONFIG` | Connection string (from Vercel dashboard, e.g. `https://edge-config.vercel.com/ecfg_xxx?token=yyy`) |
457
+ | `EDGE_CONFIG_ID` | Edge Config store ID (e.g. `ecfg_xxx`) — passed as `configID` |
458
+ | `VERCEL_REST_API_ACCESS_TOKEN` | Vercel REST API token with read/write access — passed as `vercelRestAPIAccessToken` |
459
+
460
+ **Options:**
461
+
462
+ | Option | Type | Default | Description |
463
+ |---|---|---|---|
464
+ | `configID` | `string` | required | Edge Config store ID |
465
+ | `configURL` | `string` | required | Full Edge Config URL (same as `EDGE_CONFIG` env var) |
466
+ | `vercelRestAPIAccessToken` | `string` | required | Vercel REST API access token |
467
+ | `teamID` | `string` | — | Vercel team ID (for team-scoped projects) |
468
+ | `manifestKey` | `string` | `'ab-testing'` | Top-level key in Edge Config that holds the manifest object |
469
+
470
+ **Usage:**
471
+ ```ts
472
+ import { vercelEdgeAdapter } from '@focus-reactive/payload-plugin-ab/adapters/vercel-edge'
473
+
474
+ const storage = vercelEdgeAdapter({
475
+ configID: process.env.EDGE_CONFIG_ID!,
476
+ configURL: process.env.EDGE_CONFIG!,
477
+ vercelRestAPIAccessToken: process.env.VERCEL_REST_API_ACCESS_TOKEN!,
478
+ teamID: process.env.VERCEL_TEAM_ID, // optional
479
+ manifestKey: 'ab-testing', // optional, this is the default
480
+ })
481
+ ```
482
+
483
+ **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.
484
+
485
+ **How it reads:** Uses `@vercel/edge-config`'s `get(manifestKey)` — this is edge-compatible and extremely fast.
486
+
487
+ ---
488
+
489
+ ## Middleware
490
+
491
+ ### createResolveAbRewrite
492
+
493
+ 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.
494
+
495
+ **Import:**
496
+ ```ts
497
+ import { createResolveAbRewrite } from '@focus-reactive/payload-plugin-ab/middleware'
498
+ ```
499
+
500
+ **Factory config — `ResolveAbRewriteConfig<TVariantData>`:**
501
+
502
+ | Field | Type | Required | Description |
503
+ |---|---|---|---|
504
+ | `storage` | `StorageAdapter<TVariantData>` | ✓ | Same adapter passed to the plugin. |
505
+ | `getBucket` | `(variant: TVariantData) => string` | ✓ | Extracts the bucket string from a variant record. |
506
+ | `getRewritePath` | `(variant: TVariantData) => string` | ✓ | Extracts the Next.js internal rewrite path from a variant record. |
507
+ | `getPassPercentage` | `(variant: TVariantData) => number` | — | Extracts the traffic percentage (0–100). When provided, enables weighted routing. When omitted, all variants and 'original' share equal probability. |
508
+ | `cookies` | `ResolveAbRewriteCookieConfig` | — | Cookie name and TTL overrides. See [Cookie System](#cookie-system). |
509
+
510
+ **Returned function signature:**
511
+ ```ts
512
+ resolveAbRewrite(
513
+ request: NextRequest,
514
+ visiblePathname: string, // URL the user sees — used as bucket cookie key
515
+ manifestKey: string, // key to look up in the manifest
516
+ originalRewritePath: string // internal path for 'original' bucket users
517
+ ): Promise<NextResponse | null>
518
+ ```
519
+
520
+ Returns a `NextResponse` with rewrites and cookies set, or `null` if no variant applies (the caller falls through to `NextResponse.next()`).
521
+
522
+ **Key behaviours:**
523
+
524
+ | Behaviour | Detail |
525
+ |---|---|
526
+ | **Sticky sessions** | Bucket is stored in a per-path cookie so the same user always sees the same variant. |
527
+ | **Weighted routing** | When `getPassPercentage` is provided, variants are selected proportionally. Remaining traffic (100 − sum) goes to 'original'. |
528
+ | **Uniform routing** | When `getPassPercentage` is omitted, each variant and 'original' share equal (1/(n+1)) probability. |
529
+ | **Visitor ID** | A persistent visitor ID (`ab_visitor_id`) is generated on first visit and stored in a long-lived cookie for analytics use. |
530
+ | **Experiment cookie** | A per-experiment cookie records the assigned bucket so analytics hooks can read it client-side. |
531
+ | **Error safety** | `storage.read()` is wrapped in try/catch; any adapter error results in `null` (pass through). |
532
+
533
+ **Full usage example:**
534
+ ```ts
535
+ import { NextResponse } from 'next/server'
536
+ import type { NextRequest } from 'next/server'
537
+ import { createResolveAbRewrite } from '@focus-reactive/payload-plugin-ab/middleware'
538
+ import { payloadGlobalAdapter } from '@focus-reactive/payload-plugin-ab/adapters/payload-global'
539
+
540
+ type ABVariantData = {
541
+ bucket: string
542
+ rewritePath: string
543
+ passPercentage: number
544
+ }
545
+
546
+ const storage = payloadGlobalAdapter<ABVariantData>({
547
+ serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
548
+ })
549
+
550
+ const resolveAbRewrite = createResolveAbRewrite<ABVariantData>({
551
+ storage,
552
+ getBucket: (v) => v.bucket,
553
+ getRewritePath: (v) => v.rewritePath,
554
+ getPassPercentage: (v) => v.passPercentage,
555
+ cookies: {
556
+ bucketCookiePrefix: 'payload_ab_bucket', // default
557
+ visitorIdCookieName: 'ab_visitor_id', // default
558
+ visitorIdMaxAge: 60 * 60 * 24 * 365, // 1 year (default)
559
+ expCookieMaxAge: 60 * 60 * 24 * 90, // 90 days (default)
560
+ },
561
+ })
562
+
563
+ export async function middleware(request: NextRequest) {
564
+ const { pathname } = request.nextUrl
565
+
566
+ const result = await resolveAbRewrite(
567
+ request,
568
+ pathname, // visiblePathname
569
+ pathname, // manifestKey
570
+ pathname, // originalRewritePath
571
+ )
572
+
573
+ return result ?? NextResponse.next()
574
+ }
575
+ ```
576
+
577
+ 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:
578
+
579
+ ```ts
580
+ // Example: locale-prefixed paths where the manifest was written with a locale prefix
581
+ const internalPath = `/en/pages${pathname}`
582
+
583
+ await resolveAbRewrite(
584
+ request,
585
+ pathname, // what the user sees (e.g. '/about')
586
+ internalPath, // manifest lookup key (e.g. '/en/pages/about')
587
+ internalPath, // rewrite for 'original' bucket
588
+ )
589
+ ```
590
+
591
+ ---
592
+
593
+ ### Weighted Traffic Distribution
594
+
595
+ When `getPassPercentage` is supplied to `createResolveAbRewrite`, variant selection is weighted:
596
+
597
+ - Each variant receives exactly its `passPercentage`% of traffic.
598
+ - The original page receives `100 − sum(passPercentage)` percent.
599
+ - If the sum of all variant percentages equals 100, no traffic reaches the original page.
600
+
601
+ 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.
602
+
603
+ ```ts
604
+ // Example: Variant A = 30%, Variant B = 50%, Original = 20%
605
+ type ABVariantData = {
606
+ bucket: string
607
+ rewritePath: string
608
+ passPercentage: number
609
+ }
610
+
611
+ const resolveAbRewrite = createResolveAbRewrite<ABVariantData>({
612
+ storage,
613
+ getBucket: (v) => v.bucket,
614
+ getRewritePath: (v) => v.rewritePath,
615
+ getPassPercentage: (v) => v.passPercentage,
616
+ })
617
+ ```
618
+
619
+ 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.
620
+
621
+ ---
622
+
623
+ ### Cookie System
624
+
625
+ The middleware writes up to three cookies on first assignment:
626
+
627
+ | Cookie | Default name | Lifetime | Purpose |
628
+ |---|---|---|---|
629
+ | 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. |
630
+ | Visitor ID cookie | `ab_visitor_id` | 365 days | Persistent cross-session visitor identifier. Used for analytics. |
631
+ | Experiment cookie | `exp_{encodeURIComponent(manifestKey)}` | 90 days | Records the assigned bucket in a named cookie. Analytics hooks read this client-side. |
632
+
633
+ **Sharing cookie config between middleware and analytics:**
634
+
635
+ Define an `AbCookieConfig` object once and pass it to both `createResolveAbRewrite` and the analytics utilities to keep cookie names in sync automatically:
636
+
637
+ ```ts
638
+ // lib/abCookies.ts
639
+ import type { AbCookieConfig } from '@focus-reactive/payload-plugin-ab/middleware'
640
+
641
+ export const abCookies: AbCookieConfig = {
642
+ visitorIdCookieName: 'my_visitor_id', // override visitor ID cookie name
643
+ getExpCookieName: (key) => `ab_exp_${key}`, // override experiment cookie name
644
+ }
645
+ ```
646
+
647
+ ```ts
648
+ // middleware.ts
649
+ import { abCookies } from './lib/abCookies'
650
+
651
+ const resolveAbRewrite = createResolveAbRewrite({
652
+ storage,
653
+ getBucket: ...,
654
+ getRewritePath: ...,
655
+ cookies: abCookies, // pass the shared config
656
+ })
657
+ ```
658
+
659
+ **`resolveAbCookieNames(config, experimentId)`:**
660
+
661
+ Use this utility in Server Components to derive cookie names as plain strings that can be safely passed as props to Client Components:
662
+
663
+ ```ts
664
+ // app/[locale]/[slug]/page.tsx (Server Component)
665
+ import { resolveAbCookieNames } from '@focus-reactive/payload-plugin-ab/middleware'
666
+ import { abCookies } from '@/lib/abCookies'
667
+
668
+ export default async function Page({ params }) {
669
+ const experimentId = `/${params.locale}/${params.slug}`
670
+ const { variantCookieName, visitorCookieName } = resolveAbCookieNames(
671
+ abCookies,
672
+ experimentId,
673
+ )
674
+
675
+ return (
676
+ <ExperimentTracker
677
+ experimentId={experimentId}
678
+ variantCookieName={variantCookieName}
679
+ visitorCookieName={visitorCookieName}
680
+ />
681
+ )
682
+ }
683
+ ```
684
+
685
+ | Parameter | Type | Description |
686
+ |---|---|---|
687
+ | `config` | `AbCookieConfig \| undefined` | Cookie config (or `undefined` to use all defaults) |
688
+ | `experimentId` | `string` | The experiment/manifest key (e.g. `'/en/about'`) |
689
+
690
+ Returns `{ variantCookieName: string, visitorCookieName: string }`.
691
+
692
+ ---
693
+
694
+ ## Analytics
695
+
696
+ 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.
697
+
698
+ ### AnalyticsAdapter Interface
699
+
700
+ ```ts
701
+ interface AnalyticsAdapter {
702
+ /** Fire when a user is assigned to and shown a variant (client-side). */
703
+ trackImpression(args: TrackImpressionArgs): void
704
+
705
+ /** Fire when a user completes a conversion goal (client-side). */
706
+ trackConversion(args: TrackConversionArgs): void
707
+
708
+ /** Optional: fire an impression server-side (RSC / Server Action / middleware). */
709
+ trackImpressionServer?(args: TrackImpressionArgs): Promise<void>
710
+
711
+ /** Optional: fetch aggregated stats for an experiment. */
712
+ getStats?(experimentId: string, dateRange?: DateRange): Promise<ExperimentStats>
713
+ }
714
+
715
+ interface TrackImpressionArgs {
716
+ experimentId: string // e.g. "/en/about"
717
+ variantBucket: string // e.g. "a" | "b" | "original"
718
+ visitorId: string
719
+ locale?: string
720
+ metadata?: Record<string, string | number | boolean>
721
+ }
722
+
723
+ interface TrackConversionArgs {
724
+ experimentId: string
725
+ variantBucket: string
726
+ visitorId: string
727
+ goalId: string // e.g. "cta_click", "purchase"
728
+ goalValue?: number
729
+ locale?: string
730
+ metadata?: Record<string, string | number | boolean>
731
+ }
732
+ ```
733
+
734
+ You can implement `AnalyticsAdapter` yourself to connect any analytics backend.
735
+
736
+ ### ABAnalyticsProvider
737
+
738
+ Wrap your app (or a page subtree) with `ABAnalyticsProvider` to make the analytics adapter available to all child components via React context:
739
+
740
+ ```tsx
741
+ // app/layout.tsx
742
+ import { ABAnalyticsProvider } from '@focus-reactive/payload-plugin-ab/analytics/client'
743
+ import { googleAnalyticsAdapter } from '@focus-reactive/payload-plugin-ab/analytics/adapters/google-analytics'
744
+
745
+ const analyticsAdapter = googleAnalyticsAdapter({
746
+ measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID!,
747
+ })
748
+
749
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
750
+ return (
751
+ <html>
752
+ <body>
753
+ <ABAnalyticsProvider adapter={analyticsAdapter}>
754
+ {children}
755
+ </ABAnalyticsProvider>
756
+ </body>
757
+ </html>
758
+ )
759
+ }
760
+ ```
761
+
762
+ ### ExperimentTracker
763
+
764
+ 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.
765
+
766
+ ```tsx
767
+ // app/[locale]/[slug]/page.tsx (Server Component)
768
+ import { ExperimentTracker } from '@focus-reactive/payload-plugin-ab/analytics/client'
769
+ import { resolveAbCookieNames } from '@focus-reactive/payload-plugin-ab/middleware'
770
+ import { abCookies } from '@/lib/abCookies'
771
+
772
+ export default async function Page({ params }) {
773
+ const experimentId = `/${params.locale}/${params.slug}`
774
+ const { variantCookieName, visitorCookieName } = resolveAbCookieNames(
775
+ abCookies,
776
+ experimentId,
777
+ )
778
+
779
+ return (
780
+ <>
781
+ {/* page content */}
782
+ <ExperimentTracker
783
+ experimentId={experimentId}
784
+ variantCookieName={variantCookieName}
785
+ visitorCookieName={visitorCookieName}
786
+ />
787
+ </>
788
+ )
789
+ }
790
+ ```
791
+
792
+ **`ExperimentTracker` props:**
793
+
794
+ | Prop | Type | Default | Description |
795
+ |---|---|---|---|
796
+ | `experimentId` | `string` | required | The experiment identifier (manifest key). |
797
+ | `variantCookieName` | `string` | `exp_${experimentId}` | Cookie holding the assigned bucket. Use `resolveAbCookieNames` to derive from a shared config. |
798
+ | `visitorCookieName` | `string` | `'ab_visitor_id'` | Cookie holding the visitor ID. |
799
+
800
+ Impressions are deduplicated with `sessionStorage` — one event per experiment per browser session regardless of re-renders.
801
+
802
+ ### useABConversion Hook
803
+
804
+ Use this hook to track conversion goals. Call the returned function from any user interaction handler:
805
+
806
+ ```tsx
807
+ 'use client'
808
+
809
+ import { useABConversion } from '@focus-reactive/payload-plugin-ab/analytics/client'
810
+
811
+ // experimentId and cookie names are resolved in a Server Component parent
812
+ // and passed down as props
813
+ export function CTAButton({
814
+ experimentId,
815
+ variantCookieName,
816
+ visitorCookieName,
817
+ }: {
818
+ experimentId: string
819
+ variantCookieName: string
820
+ visitorCookieName: string
821
+ }) {
822
+ const trackConversion = useABConversion({
823
+ experimentId,
824
+ variantCookieName,
825
+ visitorCookieName,
826
+ })
827
+
828
+ return (
829
+ <button onClick={() => trackConversion({ goalId: 'cta_click', goalValue: 1 })}>
830
+ Get Started
831
+ </button>
832
+ )
833
+ }
834
+ ```
835
+
836
+ **`useABConversion` options:**
837
+
838
+ | Option | Type | Default | Description |
839
+ |---|---|---|---|
840
+ | `experimentId` | `string` | required | The experiment identifier. |
841
+ | `variantCookieName` | `string` | `exp_${experimentId}` | Cookie holding the assigned bucket. |
842
+ | `visitorCookieName` | `string` | `'ab_visitor_id'` | Cookie holding the visitor ID. |
843
+
844
+ Returns `TrackConversionFn`: `(args: { goalId: string; goalValue?: number }) => void`.
845
+
846
+ The hook reads both cookies client-side. If either cookie is missing (user not assigned to any experiment), the call is a no-op.
847
+
848
+ ### Google Analytics Adapter
849
+
850
+ 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.
851
+
852
+ **Import:**
853
+ ```ts
854
+ import { googleAnalyticsAdapter } from '@focus-reactive/payload-plugin-ab/analytics/adapters/google-analytics'
855
+ ```
856
+
857
+ **Config — `GoogleAnalyticsAdapterConfig`:**
858
+
859
+ | Option | Type | Required | Description |
860
+ |---|---|---|---|
861
+ | `measurementId` | `string` | ✓ | GA4 Measurement ID, e.g. `'G-XXXXXXXXXX'` |
862
+ | `apiSecret` | `string` | — | GA4 Measurement Protocol API secret. Enables `trackImpressionServer()`. |
863
+ | `propertyId` | `string` | — | GA4 property resource name, e.g. `'properties/123456789'`. Required for `getStats()`. |
864
+ | `getAccessToken` | `() => Promise<string>` | — | Returns a valid OAuth2 access token for the GA4 Data API (scope: `analytics.readonly`). Required for `getStats()`. |
865
+ | `impressionEventName` | `string` | — | Custom GA4 event name for impressions. Default: `'ab_impression'` |
866
+ | `conversionEventName` | `string` | — | Custom GA4 event name for conversions. Default: `'ab_conversion'` |
867
+
868
+ **Full setup example:**
869
+ ```ts
870
+ import { googleAnalyticsAdapter } from '@focus-reactive/payload-plugin-ab/analytics/adapters/google-analytics'
871
+ import { GoogleAuth } from 'google-auth-library'
872
+
873
+ const auth = new GoogleAuth({
874
+ scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
875
+ })
876
+
877
+ const analyticsAdapter = googleAnalyticsAdapter({
878
+ measurementId: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID!,
879
+ apiSecret: process.env.GA4_API_SECRET, // enables server-side impression tracking
880
+ propertyId: process.env.GA4_PROPERTY_ID, // enables getStats()
881
+ getAccessToken: () => auth.getAccessToken(), // enables getStats()
882
+ })
883
+ ```
884
+
885
+ **GA4 event parameters sent:**
886
+
887
+ *Impression (`ab_impression` by default):*
888
+ - `experiment_id` — manifest key / experiment ID
889
+ - `variant_bucket` — assigned bucket (`'a'`, `'b'`, `'original'`, etc.)
890
+ - `visitor_id` — persistent visitor identifier
891
+ - `locale` — if provided
892
+ - any extra `metadata` key/value pairs
893
+
894
+ *Conversion (`ab_conversion` by default):*
895
+ - all impression parameters, plus:
896
+ - `goal_id` — conversion goal identifier
897
+ - `value` — numeric goal value (if provided)
898
+
899
+ ---
900
+
901
+ ## Multi-Tenant Support
902
+
903
+ 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.
904
+
905
+ Add `tenantField` to your `CollectionABConfig`:
906
+
907
+ ```ts
908
+ abTestingPlugin<ABVariantData>({
909
+ storage,
910
+ collections: {
911
+ page: {
912
+ variantCollectionSlug: 'page-variants',
913
+ tenantField: 'tenant', // dot-notation path to the tenant field on the parent document
914
+ generatePath: ...,
915
+ generateVariantData: ...,
916
+ },
917
+ },
918
+ })
919
+ ```
920
+
921
+ 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.
922
+
923
+ ---
924
+
925
+ ## Localization Support
926
+
927
+ If your Payload config has `localization` enabled, the plugin automatically handles it. For every locale defined in `payload.config.localization.locales`, the hooks:
928
+
929
+ 1. Fetch the parent document in that locale.
930
+ 2. Call `generatePath({ doc, locale })` to determine the manifest key.
931
+ 3. Call `generateVariantData({ doc, variantDoc, locale })` for each variant.
932
+ 4. Write a separate manifest entry per locale.
933
+
934
+ This means you can generate locale-prefixed paths from any field on the document:
935
+
936
+ ```ts
937
+ generatePath: ({ doc, locale }) => {
938
+ const slug = doc.slug as string | undefined
939
+ if (!slug) return null
940
+
941
+ return locale ? `/${locale}/${slug}` : `/${slug}` // e.g. '/en/about', '/fr/about'
942
+ },
943
+ ```
944
+
945
+ 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.
946
+
947
+ ---
948
+
949
+ ## TypeScript Generics
950
+
951
+ The plugin is fully generic over `TVariantData extends object`. Define your variant shape once and it flows through the entire stack:
952
+
953
+ ```ts
954
+ // Define your variant shape — include everything middleware needs to route the request
955
+ type ABVariantData = {
956
+ bucket: string // which bucket ('a' | 'b' | 'c')
957
+ rewritePath: string // internal URL middleware rewrites to
958
+ passPercentage: number // traffic weight for weighted routing
959
+ }
960
+
961
+ // 1. Pass the type to the storage adapter
962
+ const storage = payloadGlobalAdapter<ABVariantData>({ serverURL: '...' })
963
+
964
+ // 2. Pass the same type to the plugin
965
+ abTestingPlugin<ABVariantData>({
966
+ storage,
967
+ collections: {
968
+ page: {
969
+ variantCollectionSlug: 'page-variants',
970
+ generatePath: ({ doc, locale }) => {
971
+ const slug = doc.slug as string | undefined
972
+ return slug ? (locale ? `/${locale}/${slug}` : `/${slug}`) : null
973
+ },
974
+ // variantDoc is Record<string, unknown> — cast fields as needed
975
+ generateVariantData: ({ doc, variantDoc, locale }): ABVariantData => ({
976
+ bucket: variantDoc.bucketID as string,
977
+ rewritePath: locale
978
+ ? `/${locale}/variants/${variantDoc.bucketID}/${doc.slug}`
979
+ : `/variants/${variantDoc.bucketID}/${doc.slug}`,
980
+ passPercentage: (variantDoc.passPercentage as number) ?? 0,
981
+ }),
982
+ },
983
+ },
984
+ })
985
+
986
+ // 3. Pass the same type to the middleware factory
987
+ const resolveAbRewrite = createResolveAbRewrite<ABVariantData>({
988
+ storage,
989
+ getBucket: (v) => v.bucket, // v is fully typed as ABVariantData
990
+ getRewritePath: (v) => v.rewritePath,
991
+ getPassPercentage: (v) => v.passPercentage,
992
+ })
993
+
994
+ // In middleware: storage.read(path) returns ABVariantData[] | null — fully typed
995
+ ```
996
+
997
+ ---
998
+
999
+ ## Exports Reference
1000
+
1001
+ | Import path | Exports |
1002
+ |---|---|
1003
+ | `@focus-reactive/payload-plugin-ab` | `abTestingPlugin`, types: `AbTestingPluginConfig`, `CollectionABConfig`, `StorageAdapter`, `AbCookieConfig`, `resolveAbCookieNames`, `ResolvedAbCookieNames` |
1004
+ | `@focus-reactive/payload-plugin-ab/adapters/payload-global` | `payloadGlobalAdapter` |
1005
+ | `@focus-reactive/payload-plugin-ab/adapters/vercel-edge` | `vercelEdgeAdapter` |
1006
+ | `@focus-reactive/payload-plugin-ab/middleware` | `createResolveAbRewrite`, types: `ResolveAbRewriteConfig`, `ResolveAbRewriteCookieConfig`, `AbCookieConfig`, `resolveAbCookieNames`, `ResolvedAbCookieNames` |
1007
+ | `@focus-reactive/payload-plugin-ab/analytics` | Types only: `AnalyticsAdapter`, `TrackImpressionArgs`, `TrackConversionArgs`, `ExperimentStats`, `VariantStats`, `DateRange`, `AbCookieConfig`, `resolveAbCookieNames`, `ResolvedAbCookieNames` |
1008
+ | `@focus-reactive/payload-plugin-ab/analytics/client` | `ABAnalyticsProvider`, `ExperimentTracker`, `useABConversion`, types: `ExperimentTrackerProps`, `UseABConversionOptions`, `TrackConversionFn`, `AbCookieConfig`, `resolveAbCookieNames`, `ResolvedAbCookieNames` |
1009
+ | `@focus-reactive/payload-plugin-ab/analytics/adapters/google-analytics` | `googleAnalyticsAdapter`, `GoogleAnalyticsAdapterConfig` |