@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.
Installation
Section titled “Installation”Add the package to a project:
npm install @storyblok/experiments@latestThe package exposes several standalone utilities. Each one works independently and can be combined as needed.
resolveExperiment
Section titled “resolveExperiment”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.
assignVariant
Section titled “assignVariant”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 variantconst 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
Section titled “Adapters”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.
Complete example
Section titled “Complete example”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" });Factory
Section titled “Factory”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" }); // conversionThe 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 }),});Further resources
Section titled “Further resources”Was this page helpful?
This site uses reCAPTCHA and Google's Privacy Policy (opens in a new window).Terms of Service (opens in a new window) apply.
Get in touch with the Storyblok community