A/B testing
Deterministic variant assignment, event tracking, and pluggable analytics.
The A/B testing plugin runs experiments on content. It assigns visitors to variants deterministically, records impressions and conversions from block events, and forwards results to analytics. It has a server half and a client half.
Installation
Add the server plugin
import { createCMS } from '@createcms/core';
import { abTest } from '@createcms/core/plugins/ab-test';
export const cms = createCMS({
db,
collections,
media,
authMiddleware,
plugins: [abTest()],
});Update the database
The plugin adds tables for tests, variants, and events. Regenerate the schema file, then apply it to your database with your Drizzle migration workflow:
npx createcms generate
npx drizzle-kit generate && npx drizzle-kit migrateThe drizzle-kit commands need a drizzle.config.ts at your project root — see the Quickstart for the one to create.
Add the client plugin
import { createCMSClient } from '@createcms/core/react';
import { abTestClient } from '@createcms/core/plugins/ab-test/client';
import type { cms } from './cms';
export const cmsClient = createCMSClient<typeof cms>()({
baseURL: '/api/cms',
plugins: [abTestClient()],
});Usage
The client exposes an abTest namespace. Record an impression for the variant a visitor sees, read their assignment, and manage consent:
cmsClient.abTest.recordImpression(testId, branchId);
const { variantId, branchId, assignedAt } = await cmsClient.abTest.getVariant(testId);
cmsClient.abTest.setConsent({ analytics_storage: 'granted' });| Action | Signature | Purpose |
|---|---|---|
recordImpression | (testId, branchId) => void | Record that a variant was shown. |
useImpression | (testId, branchId) => void | React hook form of recordImpression. |
getVariant | (testId) => Promise<{ variantId, branchId, assignedAt }> | Read the visitor's assignment. |
setConsent / getConsent | Consent Mode v2 signals | Gate analytics on consent. |
dispatchEvent | (event) => void | Dispatch a client event. |
identify / reset | visitor context | Set or clear the visitor key. |
Live results
useLiveResults streams result deltas to the dashboard over the public ab:live:<testId> channel. It's imported from its own subpath (which pulls in the optional @upstash/realtime peer) and rides the shared RealtimeProvider connection — the same one useNotifications uses:
import { useLiveResults } from '@createcms/core/plugins/ab-test/live';
const { results, isLive } = useLiveResults({
testId,
initial, // SSR snapshot from getResults
getResults: () => client.abTest.getResults({ query: { testId } }), // reconcile on (re)connect
});Live delivery is decoupled from the analytics storage adapter — it works with the Postgres or Upstash adapter, as long as realtime is configured on the server. Without it the stream never connects and initial (+ any getResults reconcile) stands.
Server endpoints
The plugin registers ten endpoints under the abTest namespace (cms.api.abTest.<name> on the server, client.abTest.<name> on the client), plus the per-collection resolveAbVariant read (cms.api.<collection>.resolveAbVariant). The client convenience actions build on these, and every endpoint also surfaces as a fully typed fetcher through typeof cms, so the input and return shapes below are inferred end to end.
Resolve a Variant for a Path
Look up the single running test that varies a request path's render — the page root or a transitively-embedded block — and get back the variants to bucket across, or { test: null } when nothing applies. It carries no visitor input, so it's safe to cache from the edge. It lives as a collection endpoint (cms.api.<collection>.resolveAbVariant).
/{collection}/resolveAbVariantconst data = await cms.api.pages.resolveAbVariant({
query: { path: '/pricing' }, // required
});const { data, error } = await client.pages.resolveAbVariant({
query: { path: '/pricing' }, // required
});pathstringrequiredThe request path to resolve a running test for.
test{ testId, rootId, trafficPercentage, variants: ResolvedAbVariant[] } | nullThe single running test that varies this path (page root or an embedded block) plus the variants to bucket across, or `null` when no test applies. Each variant carries `variantId`, `branchId`, `weight`, and `isControl`.
Create a Test
Create an A/B test on a root. It opens in draft, every variant's branch must already be published, and you get back the new test's id.
abTest:create/abTest/createTestconst data = await cms.api.abTest.createTest({
body: {
rootId: 'root_home', // required
collection: 'pages', // required
name: 'Hero copy test', // required
variants: [
{ branchId: 'br_control', name: 'Control', weight: 50, isControl: true },
{ branchId: 'br_bold', name: 'Bold headline', weight: 50 },
], // required
trafficPercentage: 50, // optional, defaults to 100
},
});const { data, error } = await client.abTest.createTest({
body: {
rootId: 'root_home', // required
collection: 'pages', // required
name: 'Hero copy test', // required
variants: [
{ branchId: 'br_control', name: 'Control', weight: 50, isControl: true },
{ branchId: 'br_bold', name: 'Bold headline', weight: 50 },
], // required
trafficPercentage: 50, // optional, defaults to 100
},
});rootIdstringrequiredThe root the test runs on.
collectionstringrequiredThe collection the root belongs to.
namestringrequiredHuman-readable test name.
variants{ branchId, name, weight, isControl? }[]requiredAt least 2 variants; weights must sum to 100 with exactly one isControl.
trafficPercentagenumber= 100Percent of visitors entered into the test, 0 to 100.
goalHandlestringThe conversion goal block trackingId (handle).
goalEventstringThe conversion goal event name.
testIdstringThe id of the newly created test (opens in `draft`).
Update a Test
Rename a test, retarget its traffic or goal, replace its variants, or advance its status through the state machine. Pass null for goalHandle/goalEvent to clear the goal; you get back the test's id.
abTest:update/abTest/updateTestconst data = await cms.api.abTest.updateTest({
body: {
testId: 'test_hero', // required
status: 'running', // optional
},
});const { data, error } = await client.abTest.updateTest({
body: {
testId: 'test_hero', // required
status: 'running', // optional
},
});testIdstringrequiredThe test to update.
namestringNew test name.
status'draft' | 'running' | 'paused' | 'completed'Advance the test through its status machine.
trafficPercentagenumberNew traffic percentage, 0 to 100.
goalHandlestring | nullSet the goal handle, or null to clear it.
goalEventstring | nullSet the goal event, or null to clear it.
variants{ branchId, name, weight, isControl? }[]Replace the variants (only while draft or paused).
testIdstringThe id of the updated test.
variants needs at least two entries whose weight values sum to 100, with exactly one flagged isControl. updateTest follows the status machine draft → running, running → paused/completed, paused → running/completed, and only replaces variants while a test is draft or paused; passing null for goalHandle/goalEvent clears the goal. listTests defaults limit to 50 (max 100) and offset to 0.
Get a Test
Fetch a single test together with its variants.
abTest:read/abTest/getTestconst data = await cms.api.abTest.getTest({
query: { testId: 'test_hero' }, // required
});const { data, error } = await client.abTest.getTest({
query: { testId: 'test_hero' }, // required
});testIdstringrequiredThe test to fetch.
idstringThe test id.
rootIdstringThe root the test runs on.
collectionstringThe collection the root belongs to.
namestringThe human-readable test name.
goalHandlestring | nullThe conversion goal block trackingId, or `null` if unset.
goalEventstring | nullThe conversion goal event name, or `null` if unset.
status'draft' | 'running' | 'paused' | 'completed'The current lifecycle status.
trafficPercentagenumberPercent of visitors entered into the test.
startedAtstring | nullWhen the test first entered `running`, or `null` if it never has.
endedAtstring | nullWhen the test was `completed`, or `null` if still active.
createdBystring | nullThe user id that created the test, or `null`.
createdAtstringWhen the test was created.
updatedAtstringWhen the test was last updated.
variants{ id, branchId, name, weight, isControl }[]The test's variants, each tied to a published branch.
List Tests
Page through your tests, optionally narrowed to one collection or status.
abTest:read/abTest/listTestsconst data = await cms.api.abTest.listTests({
query: { status: 'running', limit: 50 }, // all optional
});const { data, error } = await client.abTest.listTests({
query: { status: 'running', limit: 50 }, // all optional
});collectionstringFilter by collection.
status'draft' | 'running' | 'paused' | 'completed'Filter by status.
limitnumber= 50Page size, 1 to 100.
offsetnumber= 0Rows to skip.
tests{ id, rootId, collection, name, status, trafficPercentage, ... }[]The tests on this page, newest first. Each is a test row — the same shape `getTest` returns, without `variants`.
totalnumberTotal tests matching the filters, ignoring limit/offset.
hasMorebooleanWhether more tests exist after this page.
List Goal Events
Enumerate the conversion goals you can pick for a root: one candidate per functional block instance and declared event, across the root's own tree and its embedded reusable blocks.
abTest:read/abTest/listGoalEventsconst data = await cms.api.abTest.listGoalEvents({
query: { rootId: 'root_home' }, // required
});const { data, error } = await client.abTest.listGoalEvents({
query: { rootId: 'root_home' }, // required
});rootIdstringrequiredThe root to enumerate pickable goal events for.
rootIdstringThe root the goals were enumerated for (echoed back).
goalsGoalCandidate[]One candidate per functional block instance × declared event. Each has `handle`, `blockType`, `blockId`, `event`, `name`, `params`, `inVaryingRoot`, and `hostRootId`.
Assign a Variant
Deterministically bucket a visitor into a running test server-side and get back their variant, its branch, and whether they fell inside the test. It reads (assigns without mutating test config), so its operation is read despite the POST.
abTest:read/abTest/assignVariantconst data = await cms.api.abTest.assignVariant({
body: {
testId: 'test_hero', // required
context: { key: 'visitor_abc' }, // required
},
});const { data, error } = await client.abTest.assignVariant({
body: {
testId: 'test_hero', // required
context: { key: 'visitor_abc' }, // required
},
});testIdstringrequiredThe running test to assign against.
context{ key: string, anonymous?: boolean }requiredThe visitor bucketing context; key is the stable visitor key.
variantIdstringThe variant the visitor was bucketed into.
branchIdstringThe variant's published branch to render (empty string if the variant has no branch).
inTestbooleanWhether the visitor fell inside the test's traffic percentage (`false` = serve control).
Track an Event
Record an impression, conversion, or any analytics event from a visitor. It uses a distinct abTestEvent permission resource, so an app can open it to anonymous visitors without exposing test management. A/B-attributed events must resolve to a variant of testId via variantId or branchId; it also accepts the optional analytics-stitching fields source, interactionId, and transport, and returns an empty ack.
abTestEvent:create/abTest/trackEventconst data = await cms.api.abTest.trackEvent({
body: {
eventType: 'conversion', // required
testId: 'test_hero',
branchId: 'br_bold',
},
});const { data, error } = await client.abTest.trackEvent({
body: {
eventType: 'conversion', // required
testId: 'test_hero',
branchId: 'br_bold',
},
});eventTypestringrequiredThe event name, 1 to 80 chars; blocks declare their own.
testIdstringA/B test id (required for A/B-attributed events).
variantIdstringThe variant the event belongs to.
branchIdstringServed branch; resolves the variant id server-side (Pattern A).
visitorIdstringVisitor id for the consent-gated unique-visitor / GA4 path.
anonymousboolean= falseWhether the event carries no visitor identifier.
metadataRecord<string, unknown>Arbitrary event metadata, up to 8KB serialized.
source{ handle?: string, type?: string }The source block handle and type (each up to 128 chars).
interactionIdstringFunnel grouping id shared by the attempt and success legs (up to 128 chars).
transport{ clientId?, sessionId?, engagementTimeMsec? }GA4 stitching ids, sent only when consent is granted.
consentConsentStateConsent Mode v2 signals (analytics_storage, ad_storage, ad_user_data, ad_personalization: 'granted' | 'denied'); analytics_storage 'denied' skips recording.
Get Results
Pull the aggregated results for a test — per-variant impressions, conversions, and rates, plus the totals. Bound the reporting window with from/to.
abTest:read/abTest/getResultsconst data = await cms.api.abTest.getResults({
query: { testId: 'test_hero' }, // required
});const { data, error } = await client.abTest.getResults({
query: { testId: 'test_hero' }, // required
});testIdstringrequiredThe test to aggregate results for.
fromDateStart of the reporting window (inclusive).
toDateEnd of the reporting window.
testIdstringThe test the results are for.
variantsAggregatedVariantResult[]Per-variant rollup. Each has `variantId`, `variantName`, `impressions`, `conversions`, `uniqueVisitors`, `conversionRate`, `attempts`, `completionRate`, and an `eventBreakdown`.
totalImpressionsnumberImpressions summed across all variants.
totalConversionsnumberConversions summed across all variants (for the resolved goal event).
goalEventstring | nullThe test's resolved goal event (wire name), or `null` for the goal-less default where `conversion` events count.
Flush Events
Force buffered events to persist on adapters that buffer (Upstash, say); adapters that don't throw AB_TEST_FLUSH_NOT_SUPPORTED. Pass testId to flush one test, or omit it to flush all.
abTest:update/abTest/flushEventsconst data = await cms.api.abTest.flushEvents({
body: { testId: 'test_hero' }, // optional
});const { data, error } = await client.abTest.flushEvents({
body: { testId: 'test_hero' }, // optional
});testIdstringFlush buffered events for this test; omit to flush all.
flushednumberHow many buffered events were flushed to durable storage.
Delete a Test
Delete a test. Only tests in draft or completed status can be deleted.
abTest:delete/abTest/deleteTestconst data = await cms.api.abTest.deleteTest({
body: { testId: 'test_hero' }, // required
});const { data, error } = await client.abTest.deleteTest({
body: { testId: 'test_hero' }, // required
});testIdstringrequiredThe test to delete (must be draft or completed).
testIdstringThe id of the deleted test.
Variant selection
Several functions and endpoints all "pick a variant", at different layers. Reading the ladder from the lowest-level primitive up to the entry points you actually call:
| Verb | Layer / import | What it does |
|---|---|---|
resolveVariant | primitive — @createcms/core/ab-edge | Pure deterministic hash-bucketing: (contextKey, testId, trafficPercentage, variants) → { variantId, inTest }. Everything else is built on it; you rarely call it directly. |
resolveAbVariant | endpoint — cms.api.<collection>.resolveAbVariant({ query: { path } }) | Looks up which test (if any) attaches to a path and returns its variants (AbResolveResult). Feeds the edge deciders; the middleware calls it for you. |
pickEdgeVariant | edge — @createcms/core/ab-edge | Pure edge bucketing over an AbResolveResult + a one-shot key → { branchId } (null = control). Consent-free, no persistence. Reach for it only when hand-rolling edge logic. |
decideEdgeVariant | edge — @createcms/core/ab-edge | The full framework-agnostic edge decision: reuse a prior cookie assignment or roll a fresh one, and return the rewrite path. The edge entry point — the Next.js abTestMiddleware is a thin adapter over it. |
assignVariant | endpoint — cms.api.abTest.assignVariant | Server-side deterministic assignment for a testId + context → { variantId, branchId, inTest }. The entry point for a client/server pipeline (non-edge). |
pickVariant | React — @createcms/core/react | Not assignment but the render step: given the fetched variants and an already-resolved branchId, returns the block tree to render (control fallback, strips A/B metadata). The rendering entry point. |
The three bold verbs are the intended entry points: decideEdgeVariant at the edge, assignVariant in a client/server pipeline, and pickVariant when rendering. See Edge A/B for the full edge primitive list and React for the renderer.
getPublishedContent returns one entry per published branch in variants (see Publishing). With no A/B test there is exactly one, so variants[0] is the page. With this plugin, resolve the visitor's branch and select it with pickVariant:
import { pickVariant } from '@createcms/core/react/variant';
const tree = pickVariant(variants, branchId) ?? variants[0].tree;See the pickVariant reference for the full signature.
Schema
| Table | Purpose |
|---|---|
ab_tests | One row per test: target root, goal, status (draft, running, paused, completed), traffic percentage. |
ab_test_variants | The variants of a test, each tied to a branch, with a weight and a control flag. |
ab_test_events | The analytics event store. eventType is free-form (impression, conversion, form_submit, page_view, and more); testId and variantId are nullable. |
Options
Server, abTest(options)
| Option | Type | Default | Description |
|---|---|---|---|
analytics | AbTestAnalyticsAdapter | postgresAnalytics() | Where events are stored or forwarded. |
ga4 | Ga4ServerConfig | Server-side GA4 Measurement Protocol forwarding. | |
rateLimit | AbTestRateLimitOptions | Rate-limit the anonymous event ingest. |
Client, abTestClient(options)
| Option | Type | Default | Description |
|---|---|---|---|
disableDataLayerSink | boolean | false | Stop forwarding to the browser dataLayer (use when server-side GA4 is configured). |
Client-side vs server-side measurement
By default the client forwards events to the browser dataLayer (for GTM/GA4). If you instead configure server-side forwarding with the ga4 option, set disableDataLayerSink: true on the client so the same event is not counted twice.
For edge variant assignment in middleware, use the Next.js A/B primitives from @createcms/core/next/middleware and @createcms/core/ab-edge (see Edge A/B).
Edge resolution cost
By default the edge middleware resolves the active tests with one fetch per request, and a middleware fetch bypasses the Next.js Data Cache, so it is not cached. This is intentional: edge runs as many short-lived isolates with no shared memory, and an uncached read keeps assignment correct the moment a test starts or stops. For high-traffic apps, pass the resolve option to swap in your own source (back it with Edge Config or KV) so the per-request read is served close to the edge instead of hitting your origin.
Error codes
| Code | Status | When |
|---|---|---|
AB_TEST_NOT_FOUND | 404 | The referenced test does not exist. |
AB_TEST_INVALID_STATUS | 400 | The requested status transition is not allowed by the state machine. |
AB_TEST_DUPLICATE_RUNNING | 409 | Another test is already running for the same root. |
AB_TEST_CROSS_EMBED_CONFLICT | 409 | A co-rendering root (an embedded reusable block or its host page) already has a running test — at most one A/B axis may vary per render. |
AB_TEST_BRANCH_NOT_PUBLISHED | 400 | One or more variant branches are not published. |
AB_TEST_NO_CONTEXT | 400 | No visitor context is set — identify() was not called first. |
AB_TEST_FLUSH_NOT_SUPPORTED | 400 | The active analytics adapter does not support flushing. |
AB_TEST_VARIANT_NOT_FOUND | 404 | The referenced variant does not exist. |
AB_TEST_TRACKING_ID_MISSING | 400 | A functional block (one that declares events) has no trackingId; every such block needs a non-empty one before its branch can publish. |
AB_TEST_TRACKING_ID_DUPLICATE | 400 | Two functional blocks in the branch share a trackingId; each must be unique. |
AB_TEST_TRACKING_ID_DRIFT | 409 | The set of functional trackingIds differs across a root's variant branches; it must be identical so a chosen goal exists in every arm. |