Skip to main content

Single-page apps (SPAs)

If your site is built with React, Vue, Next.js, Nuxt, SvelteKit, Astro, or any framework that swaps the page in JavaScript instead of doing a full browser navigation, you have a single-page app (SPA). ABTestly supports SPAs natively — the snippet watches for in-app route changes and re-runs your experiments without a page reload. This page is the definitive reference. It covers what to turn on, the lifecycle, the three developer helpers (onApply / onCleanup / waitFor), the canonical variant template, every common pitfall, and the guarantees we make so your results stay trustworthy.
TL;DR for impatient devs. Turn on SPA mode in your site settings. In a variant’s JavaScript field, wrap your DOM work in abtestly.onApply(ABTESTLY_EXP_ID, () => { ... }). If you need to undo anything (listeners, body classes, third-party widgets), pair it with abtestly.onCleanup(ABTESTLY_EXP_ID, () => { ... }). If the element you’re editing renders asynchronously, wrap the inside of onApply in abtestly.waitFor('.your-selector', (el) => { ... }). The full canonical template is just one click in the variant editor (Insert SPA template) — it’s what most teams paste and adapt.

1. Enable SPA mode

SPA mode is a per-site setting. In your dashboard:
1

Open the site

Sites → your site → SPA tab.
2

Turn on Enable SPA support

Toggling republishes your site config so the change reaches visitors on the next pageview.
When SPA mode is on, the snippet:
  • Wraps history.pushState and history.replaceState.
  • Listens for popstate (back/forward) and hashchange (hash routers).
  • Runs the SPA lifecycle on every detected route change.
When SPA mode is off (the default), the snippet behaves exactly like it does on a traditional multi-page site — zero overhead, zero behavior change. There’s no penalty for leaving it off on a non-SPA.

2. The SPA lifecycle

This is the single mental model the rest of the page builds on. Read it once and the helpers below will make sense.

Initial page load (any site)

  1. The snippet boots, evaluates targeting and audiences for each experiment.
  2. For every eligible experiment:
    1. A bucket is computed (or read from cache if the visitor’s been here).
    2. An impression beacon fires.
    3. The variant’s CSS is injected into <head> (tagged <style data-abtestly-exp="<id>"> so we can find it later).
    4. The variant’s JavaScript runs — once. This is where your onApply / onCleanup registrations land.
    5. Any registered onApply callbacks fire.

In-app route change (SPA mode only)

When the visitor navigates client-side — clicks a <Link>, your router calls pushState, the back button fires popstate, the hash changes — ABTestly runs this sequence:
  1. Cleanup first. Every onCleanup callback for every experiment that was active on the previous route fires. This includes experiments that are still active on the new route — so they re-apply cleanly with no double-bound listeners or duplicated styles.
  2. CSS auto-cleanup. For experiments that are truly deactivated (eligible before, not eligible now), we remove their <style data-abtestly-exp> tag from <head> automatically.
  3. Re-evaluate targeting for every experiment against the new URL. Mid-session bucketing kicks in for visitors who become eligible for the first time on this route (see Mid-session bucketing).
  4. Impressions fire for every eligible experiment (one batched beacon to /e per route change, plus a GA4 experience_impression push if you have GA4 enabled). This pass runs before any variant DOM work so a broken variant on one arm can never under-count impressions on that arm — see Trust & isolation for the SRM rationale.
  5. CSS re-inject + onApply re-fire for every eligible experiment. Variant JavaScript does NOT re-run — only your registered onApply callbacks do. (Re-running the JS would stack callback registrations on every navigation.)
  6. page_visit goals re-evaluate against the new URL — so a goal pointing at /thank-you fires on a soft nav to /thank-you. Already- fired goals dedupe per session. Listener goals (click, form-submit, etc.) are NOT re-attached — their handlers are still live.
  7. abtestly:routechange CustomEvent dispatches on window with the list of active + deactivated experiments — see The abtestly:routechange event.
That’s it. If you remember “cleanup → impression → apply”, you understand the SPA loop.

3. Writing variant code — the canonical pattern

This is the shape almost every SPA variant should take. Click Insert SPA template in the variant editor to paste it.
// ABTESTLY_EXP_ID and ABTESTLY_VARIANT_KEY are auto-bound — see section 4. No need to
// paste your experiment UUID anywhere.

// Per-experiment marker attribute. MUST be unique per experiment —
// using a shared name like `data-abt-done` collides when two tests
// target the same element (one cleanup wipes the other's guard).
const DONE_ATTR = 'data-abtestly-applied-' + ABTESTLY_EXP_ID;

window.abtestly.onApply(ABTESTLY_EXP_ID, () => {
  window.abtestly.waitFor('.product-info', (root) => {
    // 1. Idempotency guard — onApply can fire twice on rapid navigation.
    //    Per-experiment attribute so parallel tests don't collide.
    if (root.getAttribute(DONE_ATTR) === ABTESTLY_VARIANT_KEY) return;
    root.setAttribute(DONE_ATTR, ABTESTLY_VARIANT_KEY);

    // 2. Make your changes. Tag anything you inject so cleanup can find it.
    const banner = document.createElement('div');
    banner.className = 'abt-promo-banner';
    banner.setAttribute('data-abt-exp', ABTESTLY_EXP_ID);
    banner.textContent = 'New promo!';
    root.prepend(banner);

    // 3. Track listeners by reference so cleanup can unbind them.
    const onBannerClick = () => { /* ... */ };
    banner.addEventListener('click', onBannerClick);

    // 4. Stash everything cleanup will need.
    window[`__abt_state_${ABTESTLY_EXP_ID}`] = {
      root,
      listeners: [{ el: banner, type: 'click', fn: onBannerClick }],
    };
  }, 15000);   // bump the timeout for slow PDPs
});

window.abtestly.onCleanup(ABTESTLY_EXP_ID, () => {
  const state = window[`__abt_state_${ABTESTLY_EXP_ID}`];
  if (!state) return;

  // Remove everything tagged with this exp's ID — one query, no looping.
  document
    .querySelectorAll(`[data-abt-exp="${ABTESTLY_EXP_ID}"]`)
    .forEach((n) => n.remove());

  // Unbind listeners.
  state.listeners.forEach(({ el, type, fn }) => {
    try { el.removeEventListener(type, fn); } catch (e) {}
  });

  // Clear the idempotency flag so a return-visit re-applies cleanly.
  // Per-experiment attribute, so removing it never affects another test.
  if (state.root) state.root.removeAttribute(DONE_ATTR);

  delete window[`__abt_state_${ABTESTLY_EXP_ID}`];
});
The five things this template solves — every one of them is a real production bug we’ve seen:
#PatternWhy
1waitFor against a page-specific selectorStops the variant firing on a different route where a stale global says “ready”
2Per-experiment marker attribute (data-abtestly-applied-<EXP_ID>) as the idempotency guardStops a fast nav firing onApply twice — and doesn’t collide with a parallel test on the same element
3data-abt-exp tag on injected DOMOne querySelectorAll removes everything
4Listener reference tracked in window[__abt_state_*]Lets onCleanup actually unbind them
5root.removeAttribute(DONE_ATTR) in cleanupLets a return visit re-apply

4. The ABTESTLY_EXP_ID / ABTESTLY_VARIANT_KEY magic locals

Inside variant JavaScript, the snippet runs your code as if you’d written:
function (ABTESTLY_EXP_ID, ABTESTLY_VARIANT_KEY) {
  // your variant code
}
…and calls it with the experiment’s UUID and the active variant key. So inside variant JS:
  • ABTESTLY_EXP_ID is the experiment’s UUID (the string "exp_abc123...").
  • ABTESTLY_VARIANT_KEY is the key of the variant currently being applied ("control", "v1", etc.).
You never have to paste the UUID into variant code. Use the bound locals — they’re cleaner to read and immune to copy-paste errors.
Don’t redeclare them. Writing const ABTESTLY_EXP_ID = ... (or let / var) inside variant JS throws SyntaxError: Identifier 'ABTESTLY_EXP_ID' has already been declared. The whole variant then fails to apply silently — you only see the error in our internal error beacon. The dashboard editor lints for this and flags it before you save.
You can still see the UUID in the variant editor (with a copy button) when you need it outside variant code — debugging, server-side mapping, your own analytics.
Legacy aliases. Older variants written before the ABTESTLY_ prefix existed used the unprefixed names EXP_ID and VARIANT_KEY. Those are still bound at runtime as aliases to the new names, so existing variants keep working unchanged — you don’t need to rush a migration. New variants and all our docs / templates / autocomplete use the prefixed form because it’s safer (no chance of colliding with a host-page global).

5. The three helpers

All three live on window.abtestly (and window.__abtestly — see Namespace).

abtestly.onApply(experimentId, fn)

Registers a callback that runs every time the experiment becomes active on the current route — on initial page load and again on every SPA navigation back to a tested page within the session.
abtestly.onApply(ABTESTLY_EXP_ID, () => {
  document.querySelector('.cta').textContent = 'Start free trial';
});
Three behaviors worth knowing:
  • Multiple registrations stack. Calling onApply(ABTESTLY_EXP_ID, …) twice registers two callbacks; both fire on every activation, in order.
  • Late registrations auto-fire. If you register an onApply callback after the experiment is already active (devtools console, a <script> that loads after the snippet, a browser-extension userscript), the callback fires once immediately, then again on every subsequent route change. So console-based iteration works as you’d expect.
  • One callback per experiment per route. A single registered callback fires exactly once per route activation, never twice per route.

abtestly.onCleanup(experimentId, fn)

Registers a callback that runs when the visitor leaves a tested route and just before a re-apply (so changes never stack).
abtestly.onCleanup(ABTESTLY_EXP_ID, () => {
  document.body.classList.remove('variant-b');
});
What you need to clean up:
ChangeWho cleans it
Variant CSS from the editor’s CSS fieldABTestly (auto, via <style data-abtestly-exp> removal)
document.createElement(...) you injectedYou — see the tag-and-strip pattern in the template
addEventListener you addedYou — store the handler reference, call removeEventListener
Body / element class names you addedYouclassList.remove(...)
Third-party widgets you initializedYou — call their teardown / .destroy()
setInterval / setTimeout you startedYouclearInterval / clearTimeout
Anything you can’t safely auto-undo is yours. Anything we know we own (the editor’s CSS field) is ours.

abtestly.waitFor(target, callback, timeoutMs?)

SPA frameworks render asynchronously — when a route changes, the new page’s DOM and data may not exist for a few hundred milliseconds. waitFor polls every 50 ms, fires your callback once when the target becomes truthy, and gives up silently after timeoutMs (default 5000). Your target lookup, the predicate, and the callback are all wrapped in try/catch so a mistake in variant code can’t break the host page.

Signature — always three arguments

abtestly.waitFor(target, callback, timeoutMs);
Common mistake — 4 arguments silently breaks. waitFor takes three args, not four. If you want to wait on multiple conditions, pass them in an array as the first argument (see Array form).Passing four separate args binds the second function to callback and puts your real callback in the timeoutMs slot — your code never runs, and the timeout check evaluates against NaN so it never gives up either.
// ❌ WRONG — second function is treated as the callback, the real
//    callback is silently dropped.
abtestly.waitFor(
  () => isPdp(),
  () => document.querySelector('.product-card'),
  (root) => { /* never runs */ },
  15000
);

// ✅ RIGHT — array form for multiple conditions.
abtestly.waitFor(
  [() => isPdp(), '.product-card'],
  (results) => { /* runs when both are truthy */ },
  15000
);

Three forms for target

FormExampleWhat callback receives
CSS selector (string)'.product-price'The matched Element
Predicate (function returning truthy/falsy)() => window.appStore?.readynull
Array (mixed selectors + predicates)['.foo', () => window.ready]Array<Element | null> — one entry per target, in order
The array form waits for all targets to become truthy (AND semantics) before firing.

Selector form

abtestly.onApply(ABTESTLY_EXP_ID, () => {
  abtestly.waitFor('.product-price', (el) => {
    el.textContent = '$79/mo';
  });
});

Predicate form

The callback gets null — predicates don’t return an element. If you used the predicate to wait on an element, query for it inside the callback:
abtestly.onApply(ABTESTLY_EXP_ID, () => {
  abtestly.waitFor(
    () => window.appStore?.ready,
    () => {
      // No `el` arg — re-query for the DOM you need.
      document.querySelector('.cta').textContent = 'Start free trial';
    },
    8000
  );
});

Array form (multiple conditions)

Use when you want to gate on both a page-state predicate AND a DOM-element presence:
const DONE_ATTR = 'data-abtestly-applied-' + ABTESTLY_EXP_ID;

abtestly.onApply(ABTESTLY_EXP_ID, () => {
  abtestly.waitFor(
    [
      () => isPdp() || isPlp(),                              // page check
      '[data-qaid="product-card"], [data-qaid="product-price"]', // DOM
    ],
    (results) => {
      // results[0] → predicate slot → null
      // results[1] → selector slot → matched Element
      const root = results[1];
      if (root.getAttribute(DONE_ATTR) === ABTESTLY_VARIANT_KEY) return;
      root.setAttribute(DONE_ATTR, ABTESTLY_VARIANT_KEY);
      // ... your variant code, using `root`
    },
    15000
  );
});
Selector form + if guard is often cleaner than the array form for “page check AND find an element.” Pick whichever reads more clearly:
abtestly.waitFor('[data-qaid="product-card"]', (root) => {
  if (!isPdp() && !isPlp()) return;  // page-check guard
  // ... your variant code
});
Prefer waitFor over your own setInterval/setTimeout. Hand-rolled waits are the most common source of SPA data-quality bugs — a missing double-fire guard double-applies a variant, a missing timeout leaks a timer, an unguarded callback throws and breaks the page. waitFor handles all three.

6. Mid-session bucketing

A visitor enters a test the first time they reach a page the test targets — whether they landed on that page directly, or navigated to it from somewhere else inside your app. That second case is the SPA part. If a visitor lands on / and clicks through to /pricing (where your test runs), they enter the test at /pricing — not at /. ABTestly catches the in-app navigation, re-checks targeting, and counts the visitor once they reach a page where the test applies.
Why this matters statistically. “Landed there” and “navigated there inside your app” are the same person making the same trip — just via different front doors. Counting only direct landings misses roughly half of traffic on most SPAs and skews results toward whichever variant sits at the entry-point of the funnel. The standard practice for triggered experiments — count visitors when they first reach the tested page — keeps results comparable across pages.
A visitor is counted at most once per experiment on the dashboard, regardless of how many times they navigate back to the tested page. The variant they get is deterministic and sticky: mid-session bucketing uses the same calculation as initial-load bucketing, so they get the same variant either way. That changes who’s included, never the split ratio — which keeps your test statistically sound (it’s SRM-safe on the assignment side).

7. Targeting in a SPA

URL-based targeting (URL contains / starts with / regex / hostname / query string — see Targeting) works the same on SPAs as on traditional sites. ABTestly re-checks targeting on every in-app route change, so a test that targets /pricing runs on a visitor who clicks from / to /pricing. If a visitor leaves a tested page for a non-tested route, the cleanup flow (section 8) runs.
One caveat — audience conditions don’t re-evaluate. Audience attributes (device, country, cookie, custom attribute) are evaluated once — the first time the visitor becomes eligible for the experiment — and that result sticks for the session. URL and location targeting re-check on every route change; audience does not.This almost never matters because audience signals are stable within a session (device and country don’t change). The one case worth knowing: if your audience depends on a cookie that gets set mid-session (e.g. a login cookie written on a later route), ABTestly won’t pick up the change until the next full page load. Target on the URL the change leads to instead, or reach out and we’ll help.

8. Automatic cleanup

When a visitor leaves a tested route, ABTestly automatically removes any CSS your variant injected through the editor’s CSS field. Each block is tagged internally as <style data-abtestly-exp="<expId>">, so we know exactly what we own. We don’t auto-undo arbitrary JavaScript — body classes, event listeners, third-party widgets. Those live in onCleanup. We can only safely auto-clean what we know we own. On every route change, cleanup runs before re-apply — for all previously-active experiments, including those still active on the new route. That ordering guarantees your changes never stack (no double-bound listeners, no duplicated styles).

9. Goals in a SPA

Two behaviors to know:
  • page_visit goals re-evaluate on every route change. A goal pointing at /thank-you fires when the visitor soft-navigates there. Already-fired goals dedupe per session, so a back-and-forth visit to /thank-you counts once.
  • Listener-based goals (click, form-submit, scroll) are not re-attached on route change. Their handlers were installed at initial load and stay live — they share the same per-session dedup Set as the page-visit goals.
If your “thank you” page is reached via SPA navigation, page_visit will catch it. If your goal is “click the upgrade button,” install it once and it tracks across the session.

10. The abtestly:routechange event

For advanced cases, ABTestly dispatches a CustomEvent on window after each in-app route change it processes:
window.addEventListener('abtestly:routechange', (e) => {
  console.log(e.detail);
  // {
  //   url: 'https://yoursite.com/pricing',
  //   activeExperiments: ['exp_abc123'],       // active on the NEW route
  //   deactivatedExperiments: ['exp_def456'],  // were active, now aren't
  // }
});
Dispatched last in the lifecycle, so by the time your listener runs, every onCleanup and onApply has already fired. Use this when you need to coordinate something outside a single experiment’s variant code — notifying your own analytics that an experiment context changed, syncing a state store, etc.

11. Trust & isolation

SPAs make A/B testing harder to get right — async rendering, repeated page views, variant code that can throw. Here’s what ABTestly does so your numbers stay sound. You don’t configure any of it.
  • Impressions count before any variant code runs. On each route change every eligible experiment’s impression is recorded — to the ABTestly dashboard AND to your GA4 export — in a first pass, before applying any variant CSS or JavaScript. So if one variant’s code throws and another’s doesn’t, both arms still record their impression. This is how we prevent sample ratio mismatch caused by a broken variant silently under-counting one arm.
  • Each experiment is isolated. Every variant’s JavaScript, every onApply / onCleanup / waitFor callback, and our own route handler all run inside their own try/catch. A throw in one experiment can’t break the page, another experiment, or ABTestly’s tracking.
  • Bucketing is deterministic and sticky. A visitor gets the same variant whether they land on the tested page or navigate to it mid-session, and the same variant every time they return.
  • Impressions dedupe per route, not per session. A visitor who opens /pricing, leaves, and comes back records an impression each time they arrive (matching how GA4 counts page views). The dashboard still resolves that to one distinct participant. Event-level exports and participant-level results stay internally consistent.
  • Variant errors are reported. If your variant’s JavaScript throws on a customer’s browser — on apply, cleanup, or a route change — the snippet reports it back to us (first occurrence per session, so we don’t spam visitors’ connections). This is how we catch a variant that works in your testing but breaks on some real-world page, before it quietly corrupts a test.

How we verify all of this

The SPA engine ships with an automated test suite covering route detection across pushState / replaceState / popstate / hashchange, the cleanup-before-apply ordering, the impression-before-apply rule, the per-route dedup, the sticky-bucketing determinism, the waitFor helper’s timeout + single-fire guarantees, the error reporter, and a non-regression check that SPA-mode-off behaves identically to a traditional site. Every release runs the full suite before it reaches your snippet.

12. Local development & QA

Namespace: window.abtestly vs window.__abtestly

The snippet always installs the helpers under window.__abtestly (the underscored, conflict-proof name). For ergonomics it ALSO sets window.abtestly as an alias if that slot is free.
  • Most sites: use window.abtestly.onApply(...) — shorter to type, matches every example we publish, and resolves to the same object.
  • Sites that already use window.abtestly for something else: the alias is skipped; use window.__abtestly.onApply(...).
Variant code generated by the editor uses window.abtestly because the alias is set the moment the snippet runs.

abtestly.dev.apply(ABTESTLY_EXP_ID) — local QA hook

When you’re iterating on variant code on your own machine and don’t want to open a preview link every time, paste this in the devtools console (or in a browser-extension userscript) to activate the experiment for the current session:
window.abtestly.dev.apply('your-exp-id');
It does exactly two things:
  1. Marks the experiment as currently active for this session.
  2. Synchronously fires any onApply callbacks already registered for it — and any later onApply(ABTESTLY_EXP_ID, fn) calls auto-fire too.
After that, the SPA lifecycle runs normally for that experiment: an in-app navigation fires onCleanup on the way out and onApply again on the way in. No reload, no preview link.
Console / userscript only. Don’t put abtestly.dev.* calls in the variant JavaScript field. Doing so makes the apply pipeline fire twice per page load (your variant calls it, then the runtime’s normal initial-apply pass runs again) and your onApply callback ends up double-firing on every visitor. The dashboard’s variant editor flags this with a yellow warning if you forget.
Preview links (see Preview links) work natively on SPAs — the lifecycle runs the same way it would for real visitors. Navigate through your app with a preview link active and your variant follows you across routes: onCleanup fires on the way out of a tested page, onApply fires on the way in to the next eligible one. No need to refresh to make the variant re-appear.

Prefer to roll your own?

onApply / onCleanup / waitFor are convenience APIs, not requirements. If you’d rather drive SPA application from your own code — your framework’s router events, a MutationObserver, your own pushState wrapper — you have two clean exits:
  • Listen to abtestly:routechange for a per-route signal you can hook anything into.
  • Skip the helpers entirely — write plain JS in the variant code field and wire up your own listeners. ABTestly’s snippet doesn’t require onApply; it’s just a convenience for the common case.
The tradeoff: rolling your own means you handle cleanup, idempotency, and timing yourself. The helpers exist because those three are where SPA tests most often go wrong — but if you have a pattern that works for your stack, use it.

13. Common pitfalls

The mistakes we see most often. Avoid these and your SPA tests will behave.
A predicate like () => window.__NEXT_DATA__ !== undefined is truthy on the listing page AND the product page — but the product DOM doesn’t exist on the listing page. If your visitor lands on /listing, the predicate fires immediately (true everywhere), your variant tries to query a /product-only element, gets null, and breaks.Fix: wait on something that’s only present on the page you’re targeting — a PDP-specific element, a body class your app sets on that route, a query against a router-specific data attribute.
// ❌ Fires on every page where this global exists
abtestly.waitFor(() => window.__NEXT_DATA__, ...);

// ✅ Fires only on the page where this element renders
abtestly.waitFor('.product-detail__price', ...);
waitFor(target, callback, timeoutMs) is a 3-argument function. Passing four args binds the second function to callback and your real callback ends up in the timeoutMs slot — your code never runs, and the timeout check evaluates against NaN so the silent-give-up never fires either. Use the array form for multi-condition waits.See the warning under waitFor.
These are bound by the snippet as function parameters. Writing const ABTESTLY_EXP_ID = ... (or let / var) inside variant JS throws SyntaxError: Identifier 'ABTESTLY_EXP_ID' has already been declared and the variant silently fails to apply. The dashboard editor lints for this — heed the warning.
dev.apply is a local-QA hook for the devtools console or userscripts. Put it in variant JS and the apply pipeline fires twice per page load — once because your variant called it, once because the runtime’s normal initial-apply pass runs. Your onApply callbacks then double-fire on every visitor. The editor flags this.
Code at the top level of the variant JS field runs once at the moment the variant injects — not on subsequent SPA navigations. If the visitor leaves the tested route and comes back, your changes don’t re-apply.Put DOM mutations inside onApply so they re-fire on every activation.
Each SPA navigation can re-fire onApply (when the visitor returns to a tested route). Without onCleanup unbinding your listeners, you stack a fresh handler on every activation. After a few navigations the listener fires N times per event. Always pair onApply listener binds with onCleanup unbinds.The canonical template does this for you.
Some SPA frameworks fire pushState two or three times during a single navigation. Without a guard like if (root.getAttribute(DONE_ATTR) === ABTESTLY_VARIANT_KEY) return;, your onApply body runs once per fire and your DOM mutations stack. The template includes this pattern.
If two experiments both write root.dataset.abtDone (or any other shared attribute) on the same element, they collide:
  1. Experiment A sets data-abt-done="v1".
  2. Experiment B reads it, sees "v1" !== "v2", applies its own changes.
  3. Experiment A’s cleanup runs and deletes the attribute — wiping B’s guard too.
  4. On the next route activation, B re-applies on top of itself.
Use a per-experiment attribute name so the two never share state. The canonical template builds it as:
const DONE_ATTR = 'data-abtestly-applied-' + ABTESTLY_EXP_ID;

if (root.getAttribute(DONE_ATTR) === ABTESTLY_VARIANT_KEY) return;
root.setAttribute(DONE_ATTR, ABTESTLY_VARIANT_KEY);
// …in cleanup:
root.removeAttribute(DONE_ATTR);
Each experiment ends up with its own attribute (data-abtestly-applied-<exp-a-uuid>, data-abtestly-applied-<exp-b-uuid>) and they can target the same element without stepping on each other.
Audience evaluation is session-sticky (see the section 7 caveat). If you target on a cookie / attribute that gets set after the visitor first becomes eligible — most commonly a login cookie written on a later route — the change doesn’t propagate until the next full page load. Target on the URL the change leads to instead.

14. Why does GA4 show more impressions than my ABTestly dashboard?

The most common question SPA users ask. Short answer: they measure different things, and that’s expected.
  • GA4’s experience_impression is an exposure event — fires every time a visitor sees an experiment on a qualifying page view. GA4 counts events.
  • ABTestly counts distinct test participants — each visitor once per experiment.
On a SPA, a visitor can view the same tested page many times in one session (open it, navigate away, come back, navigate again). Each view fires an experience_impression event in GA4, but the dashboard still counts that visitor once. GA4’s number will usually be higher — often substantially so on SPAs — and that does not mean either side is wrong.
Compare variants in GA4 with conversion rates, not raw counts. Rates are robust to the events-vs-participants difference; raw impression counts are not. The Segment recipe in the GA4 export docs walks through the right setup, including using User segments in GA4 when you want a per-visitor view that lines up with ABTestly’s participant count.

15. Tips for clean SPA results

  • Same snippet, same place. Install the snippet the same way as on any other site — first script in <head>, no async / defer, no GTM. See Installing the snippet.
  • Prefer URL-based targeting. URL conditions just work because targeting re-checks on every route change. JavaScript conditions also work, but they evaluate against the page state at the moment of the route change — if data loads later, conditions reading that data may not match.
  • Use onApply for DOM changes, not a top-level script. Code at the top of the variant JS field runs once and never re-fires on navigation.
  • Use waitFor for anything that renders asynchronously. And wait on a page-specific thing.
  • Undo JS in onCleanup; let us handle the CSS. Editor-CSS-field rules are auto-cleaned. Listeners, body classes, and third-party initializations are yours.
  • Test with a preview link as you build. Preview is SPA-aware — navigate through your app with the link active to confirm the lifecycle behaves the way you expect.

If something specific to your app’s routing is giving unexpected results, write us at support@abtestly.com with the URL, the experiment ID, and a description of what you expected vs. what you saw. We’d rather hear about it than have you guess.