@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 +1009 -0
- package/dist/adapters/payloadGlobal/index.d.ts +15 -0
- package/dist/adapters/payloadGlobal/index.js +82 -0
- package/dist/adapters/payloadGlobal/index.js.map +1 -0
- package/dist/adapters/vercelEdge/index.d.ts +20 -0
- package/dist/adapters/vercelEdge/index.js +66 -0
- package/dist/adapters/vercelEdge/index.js.map +1 -0
- package/dist/analytics/adapters/googleAnalytics/index.d.ts +30 -0
- package/dist/analytics/adapters/googleAnalytics/index.js +188 -0
- package/dist/analytics/adapters/googleAnalytics/index.js.map +1 -0
- package/dist/analytics/client.d.ts +48 -0
- package/dist/analytics/client.js +102 -0
- package/dist/analytics/client.js.map +1 -0
- package/dist/analytics/index.d.ts +2 -0
- package/dist/analytics/index.js +19 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/config-Bq-Mi7k_.d.ts +49 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +327 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/index.d.ts +52 -0
- package/dist/middleware/index.js +118 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/resolveAbCookieNames-DH8evjWm.d.ts +32 -0
- package/dist/types-OJFBnrUD.d.ts +72 -0
- package/package.json +122 -0
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` |
|