Skip to content

@storyblok/experiments

@storyblok/experiments provides runtime A/B testing utilities for Storyblok Experiments. It assigns each visitor to a variant, decides which story to render, and tracks exposure and conversion events, reading the payload from the Content Delivery API experiments endpoint. The package is composable, framework-agnostic, and server-first.

In Storyblok, each experiment variant is a separate story. Therefore, the package’s primary function is to resolve which story slug to render. To learn more about how variants map to a main story, refer to the experiments concept. The API client fetches the story and the experiments payload separately.

Add the package to a project:

Terminal window
npm install @storyblok/experiments@latest

The package exposes several standalone utilities. Each one works independently and can be combined as needed.

Maps the experiments payload, a slug, and an assignment to the slug that should be rendered, plus an exposure descriptor. Control resolves to the original slug, and an assigned variant resolves to its mapped slug.

import { resolveExperiment } from "@storyblok/experiments";
const { slug, variant, exposure } = resolveExperiment({ experiments, slug: "home", assignment });
// Fetch the correct story variant with your existing API client. This example uses @storyblok/api-client@alpha.
const story = await client.stories.get(`cdn/stories/${slug}`);

When no experiment matches the slug, or the assignment is missing, the slug passes through unchanged and exposure is undefined.

Deterministic weighted bucketing. For a given experiment, the same visitorId always lands on the same variant, with no storage. The visitorId can be any stable identifier, such as a cookie or a header. You are responsible for persisting it.

import { assignVariant } from "@storyblok/experiments";
// sticky: same visitorId always maps to the same variant
const assignment = assignVariant({ experiment, visitorId });

assignVariant hashes visitorId plus the experiment ID into a 0..99 bucket and walks the variants’ cumulative weights, so the same visitor resolves to the same variant on every request.

Adapters deliver experiment events (exposure and conversion) to an analytics destination. An adapter is any (event) => void | Promise<unknown> function: it takes an event and delivers it to the destination. Call the adapter directly to send an event.

import { fetchAdapter } from "@storyblok/experiments/adapters";
const adapter = fetchAdapter("https://my-sink.example/events");
adapter(exposure);
const myAdapter = (event) => myAnalytics.track(event);
myAdapter({ type: "conversion", experiment, variant, name: "signup" });

fetchAdapter POSTs each event as JSON and rejects on a non-2xx response (fetch itself only rejects on a network failure), so delivery failures are observable. When you call an adapter directly, handle the returned promise. When the createExperiments factory fires events, pass onError to observe failures.

import { assignVariant, resolveExperiment } from "@storyblok/experiments";
import { fetchAdapter } from "@storyblok/experiments/adapters";
const adapter = fetchAdapter("https://my-sink.example/events");
// 1. Fetch the experiment config (cacheable, same for every visitor). This example uses @storyblok/api-client@alpha.
const { experiments } = await client.experiments.list();
// 2. Identify the visitor (you own this: cookie, header, etc.).
const visitorId = getCookie("visitor_id") ?? setCookie("visitor_id", crypto.randomUUID());
// 3. Assign: deterministic bucketing, same visitorId maps to the same variant.
const experiment = experiments.find((candidate) => candidate.name === "homepage_hero");
const assignment = assignVariant({ experiment, visitorId });
// 4. Resolve: which slug does this visitor get? (pure, no I/O)
const { slug, exposure } = resolveExperiment({ experiments, slug: "home", assignment });
// 5. Fetch and render the resolved story with your existing API client. This example uses @storyblok/api-client@alpha.
const story = await client.stories.get(`cdn/stories/${slug}`);
render(story);
// 6. Exposure: fire when the visitor actually saw the variant.
// `exposure` is undefined when no experiment applied, so guard it.
if (exposure) {
adapter(exposure);
}
// 7. Conversion: later, wherever the goal happens.
adapter({ type: "conversion", experiment, variant: assignment.variant, name: "signup" });

createExperiments pre-binds the config and adapters, auto-fires exposure on resolve, and remembers assignments so conversions attribute without re-passing context. It is designed for server-side, per-request use.

import { createExperiments } from "@storyblok/experiments";
import { fetchAdapter } from "@storyblok/experiments/adapters";
const exp = createExperiments({ experiments, adapters: [fetchAdapter(url)] });
const { slug } = exp.resolveExperiment({ slug: "home", visitorId }); // exposure auto-fired
// Fetch the correct story variant with your existing API client. This example uses @storyblok/api-client@alpha.
const story = await client.stories.get(`cdn/stories/${slug}`);
// later, on the page:
exp.track("signup", { plan: "pro" }); // conversion

The factory fires events on your behalf, so you cannot await a failing adapter. Adapter errors are swallowed by default to keep the request resilient: a downed analytics destination must never break rendering. Pass onError to observe them:

const exp = createExperiments({
experiments,
adapters: [fetchAdapter(url)],
onError: (error, event) => logger.warn("experiment event delivery failed", { error, event }),
});

Was this page helpful?

What went wrong?

This site uses reCAPTCHA and Google's Privacy Policy (opens in a new window).Terms of Service (opens in a new window) apply.