⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS

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

lib/cms.ts
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 migrate

The drizzle-kit commands need a drizzle.config.ts at your project root — see the Quickstart for the one to create.

Add the client plugin

lib/cms-client.ts
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' });
ActionSignaturePurpose
recordImpression(testId, branchId) => voidRecord that a variant was shown.
useImpression(testId, branchId) => voidReact hook form of recordImpression.
getVariant(testId) => Promise<{ variantId, branchId, assignedAt }>Read the visitor's assignment.
setConsent / getConsentConsent Mode v2 signalsGate analytics on consent.
dispatchEvent(event) => voidDispatch a client event.
identify / resetvisitor contextSet 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).

Anonymous read
GET/{collection}/resolveAbVariant
const { data, error } = await client.pages.resolveAbVariant({
  query: { path: '/pricing' }, // required
});
Parameters
pathstringrequired

The request path to resolve a running test for.

Returns
test{ testId, rootId, trafficPercentage, variants: ResolvedAbVariant[] } | null

The 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
POST/abTest/createTest
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
  },
});
Parameters
rootIdstringrequired

The root the test runs on.

collectionstringrequired

The collection the root belongs to.

namestringrequired

Human-readable test name.

variants{ branchId, name, weight, isControl? }[]required

At least 2 variants; weights must sum to 100 with exactly one isControl.

trafficPercentagenumber= 100

Percent of visitors entered into the test, 0 to 100.

goalHandlestring

The conversion goal block trackingId (handle).

goalEventstring

The conversion goal event name.

Returns
testIdstring

The 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
POST/abTest/updateTest
const { data, error } = await client.abTest.updateTest({
  body: {
    testId: 'test_hero', // required
    status: 'running', // optional
  },
});
Parameters
testIdstringrequired

The test to update.

namestring

New test name.

status'draft' | 'running' | 'paused' | 'completed'

Advance the test through its status machine.

trafficPercentagenumber

New traffic percentage, 0 to 100.

goalHandlestring | null

Set the goal handle, or null to clear it.

goalEventstring | null

Set the goal event, or null to clear it.

variants{ branchId, name, weight, isControl? }[]

Replace the variants (only while draft or paused).

Returns
testIdstring

The 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
GET/abTest/getTest
const { data, error } = await client.abTest.getTest({
  query: { testId: 'test_hero' }, // required
});
Parameters
testIdstringrequired

The test to fetch.

Returns
idstring

The test id.

rootIdstring

The root the test runs on.

collectionstring

The collection the root belongs to.

namestring

The human-readable test name.

goalHandlestring | null

The conversion goal block trackingId, or `null` if unset.

goalEventstring | null

The conversion goal event name, or `null` if unset.

status'draft' | 'running' | 'paused' | 'completed'

The current lifecycle status.

trafficPercentagenumber

Percent of visitors entered into the test.

startedAtstring | null

When the test first entered `running`, or `null` if it never has.

endedAtstring | null

When the test was `completed`, or `null` if still active.

createdBystring | null

The user id that created the test, or `null`.

createdAtstring

When the test was created.

updatedAtstring

When 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
GET/abTest/listTests
const { data, error } = await client.abTest.listTests({
  query: { status: 'running', limit: 50 }, // all optional
});
Parameters
collectionstring

Filter by collection.

status'draft' | 'running' | 'paused' | 'completed'

Filter by status.

limitnumber= 50

Page size, 1 to 100.

offsetnumber= 0

Rows to skip.

Returns
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`.

totalnumber

Total tests matching the filters, ignoring limit/offset.

hasMoreboolean

Whether 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
GET/abTest/listGoalEvents
const { data, error } = await client.abTest.listGoalEvents({
  query: { rootId: 'root_home' }, // required
});
Parameters
rootIdstringrequired

The root to enumerate pickable goal events for.

Returns
rootIdstring

The 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
POST/abTest/assignVariant
const { data, error } = await client.abTest.assignVariant({
  body: {
    testId: 'test_hero', // required
    context: { key: 'visitor_abc' }, // required
  },
});
Parameters
testIdstringrequired

The running test to assign against.

context{ key: string, anonymous?: boolean }required

The visitor bucketing context; key is the stable visitor key.

Returns
variantIdstring

The variant the visitor was bucketed into.

branchIdstring

The variant's published branch to render (empty string if the variant has no branch).

inTestboolean

Whether 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
POST/abTest/trackEvent
const { data, error } = await client.abTest.trackEvent({
  body: {
    eventType: 'conversion', // required
    testId: 'test_hero',
    branchId: 'br_bold',
  },
});
Parameters
eventTypestringrequired

The event name, 1 to 80 chars; blocks declare their own.

testIdstring

A/B test id (required for A/B-attributed events).

variantIdstring

The variant the event belongs to.

branchIdstring

Served branch; resolves the variant id server-side (Pattern A).

visitorIdstring

Visitor id for the consent-gated unique-visitor / GA4 path.

anonymousboolean= false

Whether 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).

interactionIdstring

Funnel 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.

consentConsentState

Consent 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
GET/abTest/getResults
const { data, error } = await client.abTest.getResults({
  query: { testId: 'test_hero' }, // required
});
Parameters
testIdstringrequired

The test to aggregate results for.

fromDate

Start of the reporting window (inclusive).

toDate

End of the reporting window.

Returns
testIdstring

The test the results are for.

variantsAggregatedVariantResult[]

Per-variant rollup. Each has `variantId`, `variantName`, `impressions`, `conversions`, `uniqueVisitors`, `conversionRate`, `attempts`, `completionRate`, and an `eventBreakdown`.

totalImpressionsnumber

Impressions summed across all variants.

totalConversionsnumber

Conversions summed across all variants (for the resolved goal event).

goalEventstring | null

The 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
POST/abTest/flushEvents
const { data, error } = await client.abTest.flushEvents({
  body: { testId: 'test_hero' }, // optional
});
Parameters
testIdstring

Flush buffered events for this test; omit to flush all.

Returns
flushednumber

How 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
POST/abTest/deleteTest
const { data, error } = await client.abTest.deleteTest({
  body: { testId: 'test_hero' }, // required
});
Parameters
testIdstringrequired

The test to delete (must be draft or completed).

Returns
testIdstring

The 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:

VerbLayer / importWhat it does
resolveVariantprimitive — @createcms/core/ab-edgePure deterministic hash-bucketing: (contextKey, testId, trafficPercentage, variants) → { variantId, inTest }. Everything else is built on it; you rarely call it directly.
resolveAbVariantendpoint — 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.
pickEdgeVariantedge — @createcms/core/ab-edgePure 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.
decideEdgeVariantedge — @createcms/core/ab-edgeThe 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.
assignVariantendpoint — cms.api.abTest.assignVariantServer-side deterministic assignment for a testId + context{ variantId, branchId, inTest }. The entry point for a client/server pipeline (non-edge).
pickVariantReact — @createcms/core/reactNot 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

TablePurpose
ab_testsOne row per test: target root, goal, status (draft, running, paused, completed), traffic percentage.
ab_test_variantsThe variants of a test, each tied to a branch, with a weight and a control flag.
ab_test_eventsThe analytics event store. eventType is free-form (impression, conversion, form_submit, page_view, and more); testId and variantId are nullable.

Options

Server, abTest(options)

OptionTypeDefaultDescription
analyticsAbTestAnalyticsAdapterpostgresAnalytics()Where events are stored or forwarded.
ga4Ga4ServerConfigServer-side GA4 Measurement Protocol forwarding.
rateLimitAbTestRateLimitOptionsRate-limit the anonymous event ingest.

Client, abTestClient(options)

OptionTypeDefaultDescription
disableDataLayerSinkbooleanfalseStop 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

CodeStatusWhen
AB_TEST_NOT_FOUND404The referenced test does not exist.
AB_TEST_INVALID_STATUS400The requested status transition is not allowed by the state machine.
AB_TEST_DUPLICATE_RUNNING409Another test is already running for the same root.
AB_TEST_CROSS_EMBED_CONFLICT409A 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_PUBLISHED400One or more variant branches are not published.
AB_TEST_NO_CONTEXT400No visitor context is set — identify() was not called first.
AB_TEST_FLUSH_NOT_SUPPORTED400The active analytics adapter does not support flushing.
AB_TEST_VARIANT_NOT_FOUND404The referenced variant does not exist.
AB_TEST_TRACKING_ID_MISSING400A 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_DUPLICATE400Two functional blocks in the branch share a trackingId; each must be unique.
AB_TEST_TRACKING_ID_DRIFT409The set of functional trackingIds differs across a root's variant branches; it must be identical so a chosen goal exists in every arm.

On this page