Node SDK & Hybrid Tests
Evaluate flags from Node with @splittestpro/node, record exposures and conversions, share variants with the pixel, and fill GA4 audiences.
@splittestpro/node is the Node client for server-side testing. It evaluates flags with caching, queues exposures and conversions and delivers them in the background with retries, and never throws into your request path. Node 18 or later, ESM, TypeScript types included, no runtime dependencies. On another language? Call the Evaluation API directly — it’s the same API.
Install
npm install @splittestpro/node
Create a key in Settings → API keys (Server API Keys) and put it in your server’s environment, for example as SPLITTESTPRO_API_KEY. Never ship it to a browser.
Quickstart
import { SplitTestPro } from "@splittestpro/node";
const stp = new SplitTestPro({ apiKey: process.env.SPLITTESTPRO_API_KEY });
stp.on("error", (err) => console.warn("splittestpro:", err.code, err.message));
// A flag: rollouts and kill switches.
if (await stp.isEnabled(user.id, "new-checkout")) {
renderNewCheckout();
}
// A server-side experiment: the flag's value comes from the user's variant.
const flags = await stp.evaluate(user.id, ["new-checkout"]);
const result = flags["new-checkout"];
if (result.reason === "experiment") {
stp.recordExposure({ identity: user.id, experimentId: result.experiment_id, variantId: result.variant_id });
}
// Later, when the user converts:
stp.recordConversion({ identity: user.id, eventType: "purchase", value: 49.0, currency: "USD" });
// On shutdown, deliver what is still queued.
await stp.close();
Create one client per process and reuse it — the cache and the event queue live on the instance. The options, with their defaults:
new SplitTestPro({
apiKey, // required, "stp_…"
baseUrl: "https://app.splittestpro.com",
pollIntervalMs: 30000, // how often the flag config is checked for changes
timeoutMs: 2000, // per request
fetch, // optional: your own fetch (tests, proxies)
});
identity is any stable, opaque id for the user — 1–128 printable ASCII characters, no whitespace, no @. Never an email address.
Evaluating Flags
await stp.isEnabled(identity, key, { assignments })→trueonly when the flag evaluates totrue. Any other outcome, failures included, isfalse.await stp.evaluate(identity, keys, { assignments })→ a map of key →{ value, reason, experiment_id?, variant_id? }, the same results as the evaluate call. Omitkeysto evaluate every flag (at most 50 keys per call).
evaluate never throws. If the request fails — network, timeout, a bad key — every requested flag comes back { value: false, reason: "error" } and the client emits an error event. Because false is always the safe value, a Split Test Pro outage serves your current experience.
Caching and the Kill Switch
Results are cached per identity, keys and assignments for as long as the workspace’s flag config is unchanged. Every pollIntervalMs (30 seconds by default) the client checks the config with a conditional request; when anything changed — a flag edited, killed, created or deleted, or an experiment that sets flags started or changed — it drops the cache. So a Kill in Settings reaches every server within 30 seconds, without a request per evaluation. The poll timer never keeps your process alive.
Recording Exposures
An exposure says “this user experienced this variant”. It is what enters a user into a server-side test’s results, so record it at the moment the user experiences the variant — when you render the new checkout, send the email, quote the price — not every time you evaluate. Evaluating a flag for a user who never reaches the feature, then recording an exposure, dilutes the test with users who saw nothing.
const flags = await stp.evaluate(user.id, ["new-checkout"]);
const r = flags["new-checkout"];
if (r.reason === "experiment") {
stp.recordExposure({ identity: user.id, experimentId: r.experiment_id, variantId: r.variant_id });
}
return r.value ? renderNewCheckout() : renderCheckout();
Only record exposures for experiments set to Server-side. For a test that runs On your site (including hybrid tests) the pixel records the exposure, and the API answers a server exposure with 409 pixel_owned.
Recording Conversions
stp.recordConversion({
identity: user.id,
eventType: "purchase", // your goal's event key; default "conversion"
value: order.total,
currency: "USD",
eventId: order.id, // optional: makes a conversion your code records twice count once
});
Without assignments, the server attributes the conversion to every running server-side experiment the identity is in. For a hybrid test, pass the pixel’s assignment string (below).
Delivery, Retries and Shutdown
recordExposure and recordConversion return immediately with the event’s id. Events are sent in the background every second, or as soon as 50 are waiting. Network errors, timeouts and 5xx answers are retried up to 3 times with the same event_id, so the server records each event at most once; a 429 waits for its Retry-After. Call await stp.close() on shutdown (for example on SIGTERM) to deliver what’s still queued.
The client emits delivered (with recorded and, when false, a reason: duplicate, frozen, not_running or unassigned) and error (with err.code — the API’s error code, or the SDK’s own: network_error, timeout, http_error, closed, queue_full). With no error listener, failures are silent.
React and Next.js
There is no browser SDK, and there shouldn’t be one: the key must stay on the server. With React, evaluate on the server and pass the result down as props — the component never sees the key, and the first render already has the right variant (no flicker).
// app/checkout/page.jsx — a Next.js server component
import { SplitTestPro } from "@splittestpro/node";
import { getUserId } from "@/lib/session";
import Checkout from "./Checkout";
const stp = new SplitTestPro({ apiKey: process.env.SPLITTESTPRO_API_KEY });
export default async function CheckoutPage() {
const userId = await getUserId();
const flags = await stp.evaluate(userId, ["new-checkout"]);
const r = flags["new-checkout"];
if (r.reason === "experiment") {
stp.recordExposure({ identity: userId, experimentId: r.experiment_id, variantId: r.variant_id });
}
return <Checkout newCheckout={r.value} />;
}
The same pattern works in Remix loaders, Express handlers that render templates, or an API route your client-side app calls: evaluate on the server, send the booleans down.
Hybrid Tests
In a hybrid test the pixel serves the experiment in the browser (On your site) and its variants also set flag values, so your backend can branch on the same variant. The pixel exposes its visitor id and assignment string on window.SplitTestPro:
// In the browser, with the Split Test Pro pixel loaded
const visitorId = window.SplitTestPro.getVisitorId(); // the pixel's visitor id (null if storage is blocked)
const assignments = window.SplitTestPro.getAssignments(); // "expId:varId|expId:varId", "" when none
await fetch("/api/cart", {
method: "POST",
headers: { "X-STP-Visitor": visitorId ?? "", "X-STP-Assignments": assignments },
body: JSON.stringify(cart),
});
// On your server
const visitorId = req.get("X-STP-Visitor");
const assignments = req.get("X-STP-Assignments") || "";
const flags = await stp.evaluate(visitorId, ["new-checkout"], { assignments });
// flags["new-checkout"].reason === "experiment" → experiment_id / variant_id match the pixel's assignment
- Use the pixel’s visitor id as
identity, so the pixel and your server bucket the visitor identically even before an assignment exists. - Pass
assignmentsso the visitor keeps the variant the pixel showed, including during the enrollment freeze. - Don’t call
recordExposure— the pixel records it. - For conversions only your server sees, call
recordConversionwith the sameidentityandassignments.
Both functions exist on the HTML pixel and on the Shopify theme pixel. On Shopify, window.SplitTestPro has only activate, getVisitorId and getAssignments — conversions there are recorded by the Shopify web pixel, not by trackConversion. getVisitorId() returns null only when the browser blocks storage; skip the server-side branch in that case. Winner-rollout experiments never appear in the assignment string — they record nothing.
GA4 for Server-Side Tests
The GA4 integration creates one GA4 audience per variant, matching the experience_impression event’s exp_variant_string parameter. The pixel sends that event for the tests it runs — which includes hybrid tests, so their audiences fill on their own. A Server-side test has no pixel, so its audiences stay empty until you send the event yourself. The format is a public contract:
exp_variant_string = STP-<experimentId>-<variantId>
using the experiment_id and variant_id from the evaluate response, on an event named experience_impression. Send it once, where you record the exposure.
From the page (gtag) — render the ids into the page and fire:
gtag("event", "experience_impression", {
exp_variant_string: "STP-" + experimentId + "-" + variantId,
});
From your server (Measurement Protocol) — no page involved:
await fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=${process.env.GA4_MEASUREMENT_ID}&api_secret=${process.env.GA4_MP_API_SECRET}`,
{
method: "POST",
body: JSON.stringify({
client_id: gaClientId, // the visitor's GA client id, from the _ga cookie
events: [{
name: "experience_impression",
params: { exp_variant_string: `STP-${r.experiment_id}-${r.variant_id}` },
}],
}),
},
);
client_id must be the visitor’s GA4 client id for the event to join their GA4 user; a made-up id creates a separate user.
Next Steps
- Every request and response field: Evaluation API
- Creating and rolling out flags: Feature Flags
- Keeping the key safe: Server API Keys