Skip to content

server side experimentation

Joseph Samir edited this page May 27, 2026 · 1 revision

Server-Side Experimentation with the SDK

Server-side experimentation moves the bucketing decision out of the browser and into your application server, edge worker, or backend job. The same SDK methods do the work in every Convert SDK — JavaScript today, PHP today, and the same pattern will apply to future SDKs (iOS, Android, etc.) when they ship. Only the entry point and the way the decision reaches the visitor change.

Code samples below are shown in JavaScript and PHP. The shapes (cookie-based visitor ID, request-derived audience/location properties, server-side conversion trigger) translate directly to any other server-capable SDK.

This guide covers the cases that motivate going server-side, the patterns for stable visitor identity, how to bridge a server-side decision to a client that needs to render the variant, and the options for triggering conversions from the server. Nothing here is prescriptive — pick the pattern that fits your stack. For a UI-concept-to-SDK translation reference (audience rule keys, goal trigger recipes, experience types), see From the Tracking Script to the SDK.

When Server-Side Makes Sense

Some situations where moving the decision to the server pays off:

  • SSR / static rendering: the HTML that ships to the browser already needs the variant baked in (no flicker, no client-side swap, search engines see the right content).
  • Decisions that depend on data the browser doesn't have: account-level entitlements, plan tiers, ML model output, anything you wouldn't want to expose to the client.
  • Headless commerce / Hydrogen / custom storefronts: the client surface is several distinct contexts (storefront, checkout, account area, mobile app) and the cleanest place to make one consistent decision is upstream.
  • Mobile / API backends: a native app calls your API; the decision belongs at the API boundary, not in the app shell.
  • Edge experimentation: a Cloudflare Worker / Lambda@Edge / Vercel Edge function buckets at the CDN tier and rewrites the response before it reaches the browser. See Cloudflare Workers for an edge-specific walkthrough.

If your experiment lives entirely on a single page rendered in the browser with no SSR concerns, server-side bucketing adds complexity without much payoff — Client-Side Experimentation covers that case.

Stable Visitor Identity

Server-side bucketing depends on a stable visitor ID. The SDK doesn't generate one for you — createContext(visitorId, …) accepts the ID you provide, and your bucketing decision is only as deterministic as that ID. A few approaches:

  • Read a cookie if present, set one if not. Common for browser-facing requests. The same cookie can be read by the server (to bucket) and the client (to attribute later conversions to the same visitor). Pick a stable cookie name and document it for the rest of your team.
  • Use your own session / user ID when the visitor is authenticated. Reuse whatever ID identifies the user across sessions in your system.
  • Synthesize an ID from a request fingerprint as a last resort for environments without cookies (e.g. API clients). Be aware that fingerprint-based IDs aren't stable across IP or User-Agent changes.

Whichever you pick, persist the ID before you call createContext, and make sure the same ID is used everywhere downstream — server-side rendering, client-side hydration, conversion tracking, and any other Convert SDK call for that visitor.

// Express-style example. Adapt the cookie API to your framework.
import { randomUUID } from 'node:crypto';
import ConvertSDK from '@convertcom/js-sdk';

const sdk = new ConvertSDK({ sdkKey: process.env.CONVERT_SDK_KEY });

app.use(async (req, res, next) => {
  await sdk.onReady();

  let visitorId = req.cookies['cv_vid'];
  if (!visitorId) {
    visitorId = randomUUID();
    res.cookie('cv_vid', visitorId, {
      httpOnly: false, // the browser-side SDK needs to read it too
      secure: true,
      sameSite: 'lax',
      maxAge: 365 * 24 * 60 * 60 * 1000
    });
  }
  req.convertContext = sdk.createContext(visitorId);
  next();
});

See Visitor Context & Properties for what you can attach to the visitor (visitor properties, default segments) and how they participate in audience evaluation.

Bucket at the Request Handler

Once you have a context, bucket the experiences relevant to the request. You can bucket every active experience or just the one(s) the current page cares about — whichever keeps the request handler tidy.

Important — pass the targeting context yourself. Server-side, the SDK has no window, no document, no navigator. Every URL, geo, device, browser, UTM, or cookie value your audience and location rules reference has to be derived from the incoming request and passed via locationProperties / visitorProperties. Omitting a key your rule references means the rule can't evaluate and the visitor isn't bucketed — even if every other condition matched. The full key-by-key mapping is in From the Tracking Script to the SDK.

const variation = req.convertContext.runExperience('homepage-hero', {
  locationProperties: {
    url:      req.protocol + '://' + req.get('host') + req.originalUrl,
    path:     req.path,
    hostname: req.hostname,
    referrer: req.get('referer')
  },
  visitorProperties: {
    country:     req.get('cf-ipcountry') ?? req.get('x-vercel-ip-country') ?? 'US',
    device:      detectDeviceFromUA(req.get('user-agent')),
    browser:     detectBrowserFromUA(req.get('user-agent')),
    visitorType: req.cookies['cv_vid'] ? 'returning' : 'new',
    utm_source:  req.query.utm_source,
    utm_medium:  req.query.utm_medium
  }
});

// Or, for all active experiences:
const allVariations = req.convertContext.runExperiences({
  locationProperties: { /* same shape */ },
  visitorProperties:  { /* same shape */ }
});

If most experiences share the same visitorProperties, set them once at context creation and they'll apply to every subsequent call: sdk.createContext(visitorId, { country, device, browser, ... }). Inline visitorProperties on a runExperience call override the context-level defaults for that call.

The bucketing call enqueues an exposure event automatically. In short-lived environments (serverless functions, edge workers, single PHP requests) the SDK flushes the queue at the end of the request via a built-in shutdown hook on PHP and via timers / explicit calls on Node. If your runtime is unusually short-lived (e.g. a Cloudflare Worker), call releaseQueues() before the response is sent — see Cloudflare Workers for the edge-specific flush pattern.

See Running Experiences for parameters and return-value details.

Consent

The SDK doesn't gate calls on consent. If your traffic is subject to consent rules (GDPR, ePrivacy, etc.) and the consent decision is observable on the server (e.g. via a consent cookie set client-side), gate runExperience and trackConversion behind it the same way you'd gate any other tracking:

if (req.cookies['consent'] === 'granted') {
  const variation = req.convertContext.runExperience('homepage-hero', { /* ... */ });
}

If consent is purely a client-side concern, your server can bucket freely (decisions are deterministic and local) but defer enabling network tracking until the client side observes consent — pass network: { tracking: false } at SDK init and flip it once consent is in.

Bridge the Decision to the Client (When You Need To)

If the variant only affects the server-rendered HTML, you're done — render the right thing and return the response. Many setups also need the client to know which variation the visitor is in, either to keep the experience consistent across SPA navigations or to fire client-side conversions. A few options:

Render the assignment into the HTML

Inline the variation key as a data- attribute on the root, a window.__CV__ JSON blob, or a meta tag. The client reads it on load and uses it without re-running the SDK in the browser:

<html data-cv-variations='{"homepage-hero":"version-b"}'>
  <head>
    <!-- or, equivalently, a script tag -->
    <script>window.__CV__ = {"homepage-hero":"version-b"};</script>

Cheap and works for any framework. The client doesn't need the SDK at all if it isn't going to bucket new experiences or trigger conversions itself.

Use the cookie you already set for the visitor ID

If the client-side SDK is also running (e.g. for conversions or for new bucketing decisions the server didn't make), give it the same visitor ID via the cookie you set above. createContext(cookieValue, …) produces the same bucketing decision the server made, deterministically. No bridge data needed — just keep the visitor ID consistent.

Pass the decision through your framework's data-loading layer

Frameworks like Next.js, Remix, Hydrogen, Nuxt, Astro, Laravel, and Symfony have a server-to-client data channel built in (getServerSideProps, loader, view variables, template context, etc.). Pass the bucketed variation through it like any other server-derived prop. This is usually the most natural option in those frameworks because it lines up with how you'd pass any other server-decided state.

// Remix / Hydrogen loader
export async function loader({ context, request }) {
  const cv = context.convertContext; // from your visitor-id middleware
  const variation = cv.runExperience('homepage-hero');
  return json({ variation });
}

export default function Home() {
  const { variation } = useLoaderData();
  return variation?.variationKey === 'version-b' ? <HeroB /> : <HeroA />;
}

Set a separate signal cookie

When you don't have a framework data channel and don't want to render the decision into HTML, a small additional cookie or response header that the client can read works. Use this sparingly — adding cookies has cost, and the visitor-ID cookie plus client-side runExperience already covers most cases.

Trigger Conversions

You have two broad options for conversions, and most apps use both depending on where the goal completes:

Server-side conversion

When the goal completion happens on the server (an order webhook fires, an API endpoint is hit, a backend job processes a renewal), call trackConversion from the same server context — using the same visitor ID that was used to bucket the experience earlier. This is the cleanest path when the conversion event is something your server already handles.

// Example: order webhook handler
app.post('/webhooks/order-paid', async (req, res) => {
  await sdk.onReady();
  const visitorId = req.body.customer_attributes?.cv_vid; // however you carry it
  if (!visitorId) return res.sendStatus(204);

  const ctx = sdk.createContext(visitorId);
  ctx.trackConversion('purchase-completed', {
    conversionData: [
      { key: 'amount', value: req.body.total },
      { key: 'transactionId', value: req.body.order_id }
    ]
  });
  await ctx.releaseQueues();
  res.sendStatus(204);
});

The hard part is usually carrying the visitor ID from the original bucketing context to the goal-completion context — webhooks don't have your cookies, so you typically pass the ID through whatever channel does survive: cart attributes, order metadata, a hidden form field, a custom header. Whatever you pick, write it down so future code reads from the same place.

Client-side conversion

When the goal completion happens in the browser (CTA click, form submit, in-page event), call trackConversion from the client-side SDK using the same visitor ID. The server doesn't need to be involved.

This is mechanically the same as the conversion path in Client-Side Experimentation — the only difference is that the bucketing decision was made server-side. Both decisions go to the same visitor and the same project, so the conversion attaches correctly as long as the visitor ID matches.

Hybrid (server buckets, client converts)

Common for SSR setups: the server makes the decision (so the SSR markup is consistent and pre-rendered) and the client triggers conversions (because the goal happens in the browser, post-render). Both call sites use the same visitor ID; the client uses createContext(visitorIdFromCookie) and calls trackConversion. No additional bridge data needed.

Persistence Across Sessions

Server-side, persistence usually falls out naturally — your application probably already has a database or session store you can wrap as a dataStore for the SDK to read/write bucketing decisions. Provide it at SDK init and the SDK will use it for dedup and for re-using previous bucketing decisions when a visitor returns. See Persistent DataStore for the interface and examples.

If you don't pass a dataStore, the SDK keeps bucketing state in memory for the lifetime of the current process/request — fine for stateless servers where bucketing is deterministic on every call (same visitor ID → same variation), but goal-dedup state won't carry across requests without persistence.

Putting It Together

A minimal end-to-end SSR flow with a hybrid conversion pattern.

import express from 'express';
import cookieParser from 'cookie-parser';
import { randomUUID } from 'node:crypto';
import ConvertSDK from '@convertcom/js-sdk';

const app = express();
app.use(cookieParser());

const sdk = new ConvertSDK({
  sdkKey: process.env.CONVERT_SDK_KEY,
  dataStore: yourPersistentDataStore() // see Persistent DataStore
});

// 1. Stable visitor identity
app.use(async (req, res, next) => {
  await sdk.onReady();
  let vid = req.cookies['cv_vid'];
  if (!vid) {
    vid = randomUUID();
    res.cookie('cv_vid', vid, { secure: true, sameSite: 'lax', maxAge: 31536000000 });
  }
  req.convert = { vid, ctx: sdk.createContext(vid) };
  next();
});

// 2. Server-side bucketing — variation baked into the SSR'd HTML
app.get('/', (req, res) => {
  const variation = req.convert.ctx.runExperience('homepage-hero');
  res.send(renderHTML({
    variation,
    vid: req.convert.vid // expose to the client so it can use the same ID
  }));
});

// 3. Server-side conversion on an order webhook
app.post('/webhooks/order-paid', async (req, res) => {
  await sdk.onReady();
  const vid = req.body.customer_attributes?.cv_vid;
  if (!vid) return res.sendStatus(204);
  const ctx = sdk.createContext(vid);
  ctx.trackConversion('purchase-completed', {
    conversionData: [
      { key: 'amount', value: req.body.total },
      { key: 'transactionId', value: req.body.order_id }
    ]
  });
  await ctx.releaseQueues();
  res.sendStatus(204);
});

The browser side can then either:

  • Just render the variation from the SSR'd HTML (no client SDK), or
  • Initialize a client-side SDK with the same cv_vid cookie and call trackConversion for any in-page goals — the bucketing decision the server already made will be replicated deterministically because the visitor ID matches.

Common Pitfalls

  • Different visitor IDs across calls. The server bucketed visitor abc; later the client fires a conversion as xyz. Convert can't link the two. Always keep one ID per visitor, set it once, read it everywhere.
  • Server-side SDK not awaited. createContext before onReady() (JS) / isReady() (PHP) returns true means the SDK hasn't fetched its config yet — bucketing will fail or return null. Always wait for ready, or initialize at server boot and keep the instance warm.
  • Forgetting to flush in short-lived runtimes. In Node, the SDK's batch timer might not fire before the function exits. Call releaseQueues() before returning from short-lived handlers (serverless, edge functions). PHP flushes automatically on shutdown via register_shutdown_function.
  • Re-creating the SDK per request. Heavy. Initialize the SDK once at process boot and reuse it; create a fresh Context per request from the same SDK instance.
  • Skipping the SDK and POSTing to the tracking API directly. Hand-rolling HTTP requests bypasses the SDK's payload construction, dedup, batching, and retry handling. Use trackConversion.

Clone this wiki locally