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 { data } = await client.experiments.list();
const { experiments } = data;
console.log(experiments);
// 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 and never lets a failing adapter break the request. 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 }),
});

Introduced in 1.1.0

The factory fires events without blocking the request, which is safe on a long-running server. Serverless platforms such as Vercel, Netlify, and Cloudflare, however, freeze or terminate the process as soon as the response is sent. An event delivery still in flight at that point is silently dropped: it never rejects, so onError never fires. Conversions are affected more than exposures because track typically runs right before the response goes out, while an exposure fired by resolveExperiment has the remaining render work to complete in.

Two options prevent this:

  • Pass the platform’s waitUntil function to createExperiments. The factory hands every pending delivery to it, and the platform keeps the process alive until delivery settles.
  • Await flush() before returning the response when the platform offers no waitUntil hook, or when delivery must complete before the response.

In both cases the promise never rejects, and failures still surface through onError.

Pass waitUntil from @vercel/functions:

import { createExperiments } from "@storyblok/experiments";
import { fetchAdapter } from "@storyblok/experiments/adapters";
import { waitUntil } from "@vercel/functions";
const exp = createExperiments({
experiments,
adapters: [fetchAdapter(url)],
waitUntil,
});
const { slug } = exp.resolveExperiment({ slug: "home", visitorId });
exp.track("signup"); // delivery finishes after the response, kept alive by waitUntil

In Next.js 15.1 and later, after from next/server accepts a promise and works the same way: waitUntil: after.

Pass ctx.waitUntil from the handler’s execution context:

export default {
async fetch(request, env, ctx) {
const exp = createExperiments({
experiments,
adapters: [fetchAdapter(url)],
waitUntil: (promise) => ctx.waitUntil(promise),
});
const { slug } = exp.resolveExperiment({ slug: "home", visitorId });
return new Response(await render(slug));
},
};

Netlify exposes the same hook as context.waitUntil in both Functions and Edge Functions.

Await flush() before returning the response. It resolves once every pending delivery has settled:

const exp = createExperiments({ experiments, adapters: [fetchAdapter(url)] });
const { slug } = exp.resolveExperiment({ slug: "home", visitorId });
exp.track("signup");
await exp.flush();
return response;

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.