-
Notifications
You must be signed in to change notification settings - Fork 3
client side experimentation
Fullstack projects don't ship a browser auto-tracking script. When you use the JavaScript SDK directly in the browser, you take ownership of four things the classic Convert tracking script handles for Web/V1 projects automatically:
- Trigger bucketing — decide a variation for the visitor.
- Apply the variation's changes — actually run the variation-specific code or render the variation-specific UI.
- Keep changes applied — make sure the variation stays on the page as the user navigates through your app (SPA route changes, modals, late-rendered content).
-
Trigger conversions — call
trackConversionwhen the visitor completes a goal.
This guide walks through each piece. None of the patterns below are prescriptive — they are options you can mix and match to fit your stack. Pick the simplest one that solves your case.
Scope: this guide is browser-focused, so code examples are JavaScript. For Node, edge workers, or any non-browser entry point, see Server-Side Experimentation. For Shopify-specific cases, see Shopify. For a UI-concept-to-SDK translation reference (audience rule keys, goal trigger recipes, experience types), see From the Tracking Script to the SDK.
The SDK is a regular npm package — initialize it once, create a context per visitor, and call its methods to bucket and to convert. It does not modify the DOM, watch for navigation, or run variation code for you. Anything that touches the page is on your side.
import ConvertSDK from '@convertcom/js-sdk';
const convertSDK = new ConvertSDK({ sdkKey: 'your-sdk-key' });
convertSDK.onReady().then(() => {
const context = convertSDK.createContext('visitor-unique-id');
// … use the context to bucket and convert …
});See Initialization for the full set of init options, and Visitor Context for what the visitor ID should be and how visitor properties work.
The visitor ID you pass to createContext is the only handle the SDK has on the visitor — bucketing decisions are deterministic on it, and conversions are attributed to it. The classic tracking script generates and persists a _conv_v cookie for you; with the SDK, you do that yourself.
The minimum viable pattern: read a first-party cookie if it exists; generate one if not.
const VISITOR_COOKIE = 'cv_vid';
const VISITOR_COOKIE_MAX_AGE_DAYS = 180;
function getOrSetVisitorId() {
const match = document.cookie.match(new RegExp('(?:^|; )' + VISITOR_COOKIE + '=([^;]*)'));
if (match) return decodeURIComponent(match[1]);
const id = (crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36));
const maxAge = VISITOR_COOKIE_MAX_AGE_DAYS * 24 * 60 * 60;
document.cookie =
`${VISITOR_COOKIE}=${encodeURIComponent(id)}; max-age=${maxAge}; path=/; samesite=lax${
location.protocol === 'https:' ? '; secure' : ''
}`;
return id;
}Three things to make sure of:
- Same cookie everywhere. If you also bucket on the server (see Server-Side Experimentation), use the same cookie name and value there. The server and the browser must agree on the visitor ID or their bucketing decisions and conversions won't link up.
-
Long-lived enough. The classic tracking script's
_conv_vlasts about 6 months. Shorter and you'll see returning visitors get re-bucketed and re-counted. Longer is also fine if it fits your privacy policy. - First-party. Third-party cookie restrictions in modern browsers mean any cookie set from a different domain is increasingly likely to be blocked. Set it from your own domain.
If you have a logged-in user identifier and you'd rather use that as the Convert visitor ID for authenticated sessions, you can — just be aware that bucketing changes when the identifier changes (anonymous visitor → logged-in user is a new visitor as far as the SDK is concerned). A common mitigation is to keep the anonymous cv_vid until signup, then migrate it (server-side) to the authenticated ID and use that going forward.
The classic tracking script can queue events behind a consent flag. The SDK does not — every runExperience enqueues an exposure event and every trackConversion enqueues a conversion event as soon as you call it. If your traffic is subject to consent rules (GDPR, ePrivacy, etc.), it's your responsibility to gate SDK calls behind your consent signal.
Two patterns:
// Pattern A — gate at the call site. Don't call SDK methods until consent is granted.
function runExperimentsIfConsented(context) {
if (!hasConsent()) return;
context.runExperiences({ locationProperties: { url: location.href }});
}
onConsentChange(() => runExperimentsIfConsented(context));// Pattern B — initialize the SDK with tracking disabled; enable on consent.
const sdk = new ConvertSDK({
sdkKey: 'your-sdk-key',
network: { tracking: false }
});
// … the SDK can still bucket (decisions are local) but events won't be sent …
onConsentChange(() => {
if (hasConsent()) {
// Re-create context with tracking enabled, or toggle at runtime
// and call releaseQueues() to flush anything queued so far.
}
});Pattern A is simpler and works for most stacks. Pattern B is useful if you want bucketing decisions available to your code for rendering purposes even without consent, but defer the wire traffic until consent lands.
Bucketing assigns the visitor to a variation deterministically based on the visitor ID and the experience configuration. Two methods cover the common cases:
// Bucket a specific experience by key
const variation = context.runExperience('homepage-headline');
// Bucket every active experience and return all the visitor qualifies for
const variations = context.runExperiences();When runExperience returns a BucketedVariation, the visitor was bucketed; the object carries experienceKey, variationKey, and the changes to apply. When it returns null/undefined, the visitor didn't qualify (rule mismatch, traffic allocation, inactive experience, etc.).
Important — pass the targeting context yourself. The SDK does not auto-read the URL, User-Agent, geo, or cookies. If your experiences use audience rules (country, device, browser, UTM, visitor type, etc.) or location rules (URL contains, hostname, path, etc.), you have to pass that information to
runExperience/runExperiencesvialocationPropertiesandvisitorProperties. Omitting a key your rule references means the rule can't be evaluated and the visitor won't be bucketed — even if every other condition matched.
// Pass everything your audience and location rules might reference.
const variation = context.runExperience('homepage-headline', {
locationProperties: {
url: window.location.href,
path: window.location.pathname,
hostname: window.location.hostname,
referrer: document.referrer
},
visitorProperties: {
country: resolveCountry(), // from your geo source
device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
browser: parseBrowser(navigator.userAgent), // your UA parser
visitorType: existingVisitorCookieFound ? 'returning' : 'new',
utm_source: new URLSearchParams(location.search).get('utm_source') ?? undefined,
utm_medium: new URLSearchParams(location.search).get('utm_medium') ?? undefined
}
});The exact keys to pass depend on the rules you configured in the UI. The full key-by-key mapping (which UI rule type expects which attribute key, and where to source each value from) is in From the Tracking Script to the SDK. You can also pass visitorProperties once at context creation (convertSDK.createContext(id, visitorProperties)) and they apply to every subsequent call — see Visitor Context.
See Running Experiences for the full parameter and return-value reference, and Bucketing Algorithm for how the assignment works under the hood.
The bucketing call enqueues an exposure event by default (so the visitor shows up in the experiment's exposure count). If you have a reason to skip it — for example, you're inspecting variations before deciding whether to apply them — pass enableTracking: false. Most apps should leave the default on.
Once you have the assigned variation, you have to render the variant. The SDK does not do this for you. Some options, ordered roughly from simplest to most flexible:
The most direct option — your component or template reads the variation key and renders the right thing:
const variation = context.runExperience('homepage-headline');
const variant = variation?.variationKey ?? 'control';
document.querySelector('#headline').textContent =
variant === 'short' ? 'Try it free' :
variant === 'long' ? 'Try it free for 30 days, no credit card needed' :
'Get started today';Works for any framework. Easy to read. Couples experiment IDs into view code, which is fine for short experiments but can get noisy if you run many.
Set a class on <html> or <body> derived from the variation key. Then write variant-specific CSS or query that class from any later-loading component:
const variation = context.runExperience('cta-color');
if (variation) {
document.documentElement.dataset.cvVariation = variation.variationKey;
// e.g. <html data-cv-variation="green">
}[data-cv-variation="green"] .cta { background: #2e7d32; }
[data-cv-variation="red"] .cta { background: #c62828; }Good when the change is purely visual. Plays well with framework-agnostic CSS, server-rendered HTML, and components mounted after bucketing.
Keep variant code in its own file and dynamic-import the one assigned. Keeps your main bundle small and the variant code isolated:
const variation = context.runExperience('checkout-step-order');
if (variation?.variationKey === 'inverted') {
await import('./variants/checkout-inverted.js');
}Useful when the change is non-trivial — different component tree, different event handlers — and you don't want it in the default bundle.
For React, Vue, Svelte, etc., expose the bucketed variation through your existing state mechanism (Context, store, signals). Components read it like any other piece of state and render accordingly:
// React example: a single experiment context shared across the tree
const ExpContext = React.createContext({});
function ExperimentProvider({ children }) {
const [variations, setVariations] = React.useState({});
React.useEffect(() => {
convertSDK.onReady().then(() => {
const ctx = convertSDK.createContext('visitor-unique-id');
const all = ctx.runExperiences();
const map = Object.fromEntries(all.map((v) => [v.experienceKey, v.variationKey]));
setVariations(map);
});
}, []);
return <ExpContext.Provider value={variations}>{children}</ExpContext.Provider>;
}Whatever pattern you pick, the rule is the same: one source of truth for which variation the visitor is in, decided once, read everywhere. The SDK already guarantees the bucketing is deterministic per visitor; you just need to make sure your code reads it consistently.
A static HTML page makes step 2 trivial — you bucket once, render once, done. SPAs, dynamic content, and late-loading components don't have that luxury. Variation code that ran on initial load won't automatically re-run when:
- The user navigates between routes without a full reload (
history.pushState, framework router transitions). - A modal or drawer opens with new DOM you wanted to mutate.
- A list virtualises and re-renders rows that previously had your CSS class.
A few patterns that handle this:
If your app has a router, hook its navigation event and re-apply variation logic. This is usually the cleanest option when most of your variation work is page-level:
// React Router
import { useLocation } from 'react-router-dom';
React.useEffect(() => {
applyVariations(); // your function that reads variation state and mutates DOM
}, [location.pathname]);
// Next.js / Remix / Hydrogen: same pattern, hook the framework's navigation event// Vanilla — patch history methods, or listen to the popstate event
const onRoute = () => applyVariations();
window.addEventListener('popstate', onRoute);
['pushState', 'replaceState'].forEach((m) => {
const orig = history[m];
history[m] = function (...args) { orig.apply(this, args); onRoute(); };
});When the DOM mutates without a route change — modals, infinite scroll, virtualised lists — a MutationObserver on the relevant subtree can re-apply variation styling as nodes appear:
const observer = new MutationObserver(() => applyVariationToNewNodes());
observer.observe(document.body, { childList: true, subtree: true });Two practical notes:
- Be selective about the root you observe and the filter you apply inside the callback — observing all of
document.bodywithsubtree: truewill fire on every keystroke in an input. Scope it to the container that actually re-renders. - Idempotency matters here more than anywhere else. Make sure re-running your variation logic on the same node is safe (e.g. mark mutated nodes with a
data-attribute and skip them on the next pass).
The most resilient pattern is to make variation code idempotent and run it at the moment the affected element actually renders, rather than chasing the DOM. Frameworks make this natural — render reads from variation state. Vanilla code can do the same with custom elements or render helpers:
function renderProductCard(product, container) {
const variation = variationFor('product-card-layout');
container.innerHTML = variation === 'compact'
? compactTemplate(product)
: standardTemplate(product);
}
// Every place that creates a product card calls renderProductCard.
// No global mutation, no observer, no race condition.If you can shape your variation logic this way, you avoid step 3 almost entirely — there's nothing to "keep applied" because the variation is read at render time.
If your audience rules depend on the URL (most do, in some form), calling runExperience once at app boot won't catch SPA navigations into pages where a different audience matches. You have three choices:
| Strategy | Behavior | Tradeoff |
|---|---|---|
| Bucket once at app boot | Decisions made on initial URL; SPA route changes don't re-evaluate. | Simplest. Misses experiences whose audience matches only after a navigation. |
| Re-run on every navigation, no DataStore | Decisions re-evaluated with current URL; bucketing is deterministic so the variation doesn't change for a given visitor + experience, but the exposure event fires again each time. | The server-side metrics processor dedups visitor + experience, so reports are correct. Wire traffic is N× the visitor count. |
| Re-run on every navigation, with a DataStore | Decisions cached locally; subsequent calls read from cache and skip the wire event. | Recommended for SPAs. Adds a small amount of code to wire up the DataStore (next section). |
The bucketing itself is always deterministic per visitor — re-running doesn't change the variation a visitor sees, only whether an extra exposure event goes on the wire. The DataStore option keeps the wire traffic clean.
When a visitor completes a goal — clicks a CTA, submits a form, completes a purchase — call trackConversion with the goal key:
context.trackConversion('cta-clicked');
// With revenue / transaction data:
context.trackConversion('purchase-completed', {
conversionData: [
{ key: 'amount', value: 49.99 },
{ key: 'productsCount', value: 2 },
{ key: 'transactionId', value: 'order-12345' }
]
});The SDK handles dedup natively (one conversion per visitor per experience, unless you opt into forceMultipleTransactions: true) and attaches the bucketing data so the conversion is correctly attributed to the variation. Hand-rolling an HTTP request to the tracking API instead of using trackConversion skips this and is almost never the right move — let the SDK build the payload.
See Tracking Conversions for the full attribute reference and the revenue-tracking model. For mapping each UI-configured goal trigger type (click, URL visit, form submission, JS-triggered, revenue, engagement) to a concrete trackConversion call, see From the Tracking Script to the SDK > Goal Triggers.
By default, the SDK keeps bucketing and goal state in memory. That's fine within a single page load, but a visitor who returns the next day gets a fresh in-memory state — they'll re-fire conversions for goals they already triggered, and re-fire exposure events on every navigation if you re-run runExperience. To dedup across sessions, give the SDK a dataStore at init time.
A minimal localStorage-backed DataStore that fits the SDK's two-method interface:
function localStorageDataStore({ prefix = 'cv:' } = {}) {
return {
get(key) {
if (!key) return null;
try {
const raw = localStorage.getItem(prefix + key);
return raw == null ? null : JSON.parse(raw);
} catch {
return null; // storage disabled, private mode, etc.
}
},
set(key, value) {
if (!key) return;
try {
localStorage.setItem(prefix + key, JSON.stringify(value));
} catch {
// quota exceeded or storage disabled — silently degrade to in-memory.
}
}
};
}
const sdk = new ConvertSDK({
sdkKey: 'your-sdk-key',
dataStore: localStorageDataStore()
});With this in place, the SDK reads its bucketing and goal-conversion state from localStorage on every call, so re-running runExperience on every SPA navigation is cheap (cached decision, no wire event) and conversions stay deduped across days, tabs, and reloads. For cookie-backed or server-backed alternatives, see Persistent DataStore.
A minimal end-to-end flow combining everything above. Reuses the getOrSetVisitorId helper from "Visitor Identity" and the localStorageDataStore helper from "Cross-session dedup":
import ConvertSDK from '@convertcom/js-sdk';
const sdk = new ConvertSDK({
sdkKey: 'your-sdk-key',
dataStore: localStorageDataStore()
});
sdk.onReady().then(() => {
if (!hasConsent()) return; // gate on consent — see "Consent" section
const visitorId = getOrSetVisitorId();
const context = sdk.createContext(visitorId, {
// Visitor properties carried for the lifetime of the context.
country: resolveCountry(),
device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
browser: parseBrowser(navigator.userAgent),
visitorType: document.cookie.includes('cv_vid=') ? 'returning' : 'new'
});
function bucketForCurrentRoute() {
const variations = context.runExperiences({
locationProperties: {
url: window.location.href,
path: window.location.pathname
}
});
// Apply changes — map variation keys to a root-element data attribute
document.documentElement.dataset.cvVariations = JSON.stringify(
Object.fromEntries(variations.map((v) => [v.experienceKey, v.variationKey]))
);
}
bucketForCurrentRoute();
// Re-bucket on SPA navigation. Cheap because the DataStore caches decisions.
window.addEventListener('popstate', bucketForCurrentRoute);
for (const m of ['pushState', 'replaceState']) {
const orig = history[m];
history[m] = function (...args) { orig.apply(this, args); bucketForCurrentRoute(); };
}
// Conversion: delegated click handler — survives DOM mutations.
document.body.addEventListener('click', (e) => {
if (e.target.closest('#signup-cta')) {
context.trackConversion('signup-cta-click');
}
});
});The single piece this example doesn't show is how you keep variation changes applied to the DOM — that's stack-specific (framework state, MutationObserver, render-time read). Pick a pattern from "Keep the Variation Applied" above and slot it in where the dataset.cvVariations write happens.
-
Calling
trackConversionfrom outside an SDK context. The conversion has to be issued from the sameContext(same visitor ID) that bucketed the experience; otherwise the conversion isn't attributed correctly. If your conversion fires far from the bucketing call, store the visitor ID somewhere you can read it back (cookie, localStorage) and re-create the context with the same ID. -
Different visitor IDs in different contexts. If your server bucketed visitor
abcand the client later callstrackConversionwith visitorxyz, Convert can't link the two. Keep one visitor ID, set it once, read it everywhere. -
Suppressing the bucketing event. Passing
enableTracking: falseis occasionally appropriate (e.g. previewing variations during development) but if you do it in production, your experience's exposure count will be zero and you lose the denominator for conversion-rate calculations. Leave it on for production traffic. - Skipping the SDK and POSTing to the tracking API directly. The endpoint, payload shape, and dedup logic are all the SDK's responsibility. Reproducing them by hand is brittle and easy to get wrong.
Shopify's checkout pages run in a separate context that your storefront code can't directly reach — the storefront and checkout don't share a window, can't share cookies easily, and the checkout fires its own customer events that your storefront-side trackConversion calls won't see. The pattern above gets you bucketing and conversions on the storefront fine, but checkout conversions (purchases, payment-info-submitted, etc.) need a different mechanism. See Shopify for what that looks like.
Copyrights © 2025 All Rights Reserved by Convert Insights, Inc.
Getting Started
JavaScript SDK
Core Concepts
- Experiences & Variations
- Feature Flags
- Bucketing Algorithm
- Rule Evaluation
- Segments
- Data Management
- Event System
- API Communication
How-To Guides
- Running Experiences
- Running Features
- Tracking Conversions
- Visitor Context
- Persistent DataStore
- Client-Side Experimentation
- Server-Side Experimentation
- Tracking Script → SDK
- Troubleshooting
Edge & Integrations
Contributing