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:
When SPA mode is on, the snippet:
- Wraps
history.pushStateandhistory.replaceState. - Listens for
popstate(back/forward) andhashchange(hash routers). - Runs the SPA lifecycle on every detected route change.
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)
- The snippet boots, evaluates targeting and audiences for each experiment.
- For every eligible experiment:
- A bucket is computed (or read from cache if the visitor’s been here).
- An impression beacon fires.
- The variant’s CSS is injected into
<head>(tagged<style data-abtestly-exp="<id>">so we can find it later). - The variant’s JavaScript runs — once. This is where your
onApply/onCleanupregistrations land. - Any registered
onApplycallbacks 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:
- Cleanup first. Every
onCleanupcallback 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. - 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. - 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).
- Impressions fire for every eligible experiment (one batched beacon
to
/eper route change, plus a GA4experience_impressionpush 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. - CSS re-inject +
onApplyre-fire for every eligible experiment. Variant JavaScript does NOT re-run — only your registeredonApplycallbacks do. (Re-running the JS would stack callback registrations on every navigation.) page_visitgoals re-evaluate against the new URL — so a goal pointing at/thank-youfires 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.abtestly:routechangeCustomEvent dispatches onwindowwith the list of active + deactivated experiments — see Theabtestly:routechangeevent.
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.| # | Pattern | Why |
|---|---|---|
| 1 | waitFor against a page-specific selector | Stops the variant firing on a different route where a stale global says “ready” |
| 2 | Per-experiment marker attribute (data-abtestly-applied-<EXP_ID>) as the idempotency guard | Stops a fast nav firing onApply twice — and doesn’t collide with a parallel test on the same element |
| 3 | data-abt-exp tag on injected DOM | One querySelectorAll removes everything |
| 4 | Listener reference tracked in window[__abt_state_*] | Lets onCleanup actually unbind them |
| 5 | root.removeAttribute(DONE_ATTR) in cleanup | Lets 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:
ABTESTLY_EXP_IDis the experiment’s UUID (the string"exp_abc123...").ABTESTLY_VARIANT_KEYis the key of the variant currently being applied ("control","v1", etc.).
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 onwindow.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.
- 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
onApplycallback 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).
| Change | Who cleans it |
|---|---|
| Variant CSS from the editor’s CSS field | ABTestly (auto, via <style data-abtestly-exp> removal) |
document.createElement(...) you injected | You — see the tag-and-strip pattern in the template |
addEventListener you added | You — store the handler reference, call removeEventListener |
| Body / element class names you added | You — classList.remove(...) |
| Third-party widgets you initialized | You — call their teardown / .destroy() |
setInterval / setTimeout you started | You — clearInterval / clearTimeout |
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
Three forms for target
| Form | Example | What callback receives |
|---|---|---|
| CSS selector (string) | '.product-price' | The matched Element |
| Predicate (function returning truthy/falsy) | () => window.appStore?.ready | null |
| Array (mixed selectors + predicates) | ['.foo', () => window.ready] | Array<Element | null> — one entry per target, in order |
Selector form
Predicate form
The callback getsnull — predicates don’t return an element. If you
used the predicate to wait on an element, query for it inside the
callback:
Array form (multiple conditions)
Use when you want to gate on both a page-state predicate AND a DOM-element presence:Selector form +
if guard is often cleaner than the array form for
“page check AND find an element.” Pick whichever reads more clearly: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.
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_visitgoals re-evaluate on every route change. A goal pointing at/thank-youfires when the visitor soft-navigates there. Already-fired goals dedupe per session, so a back-and-forth visit to/thank-youcounts 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.
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:
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/waitForcallback, 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 acrosspushState / 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.abtestlyfor something else: the alias is skipped; usewindow.__abtestly.onApply(...).
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:
- Marks the experiment as currently active for this session.
- Synchronously fires any
onApplycallbacks already registered for it — and any lateronApply(ABTESTLY_EXP_ID, fn)calls auto-fire too.
onCleanup on the way out and onApply again
on the way in. No reload, no preview link.
Preview links on SPAs
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:routechangefor 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.
13. Common pitfalls
The mistakes we see most often. Avoid these and your SPA tests will behave.Stale-positive `waitFor` predicates (THE classic SPA bug)
Stale-positive `waitFor` predicates (THE classic SPA bug)
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.Calling `waitFor` with 4 separate arguments
Calling `waitFor` with 4 separate arguments
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.Redeclaring `ABTESTLY_EXP_ID` or `ABTESTLY_VARIANT_KEY`
Redeclaring `ABTESTLY_EXP_ID` or `ABTESTLY_VARIANT_KEY`
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.Calling `abtestly.dev.apply` from variant code
Calling `abtestly.dev.apply` from variant code
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.DOM changes in a one-time `<script>` block instead of `onApply`
DOM changes in a one-time `<script>` block instead of `onApply`
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.Forgetting `onCleanup` for listeners
Forgetting `onCleanup` for listeners
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.Forgetting the idempotency guard
Forgetting the idempotency guard
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.Sharing one marker attribute across multiple parallel experiments
Sharing one marker attribute across multiple parallel experiments
Targeting on an audience attribute that changes mid-session
Targeting on an audience attribute that changes mid-session
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_impressionis 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.
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>, noasync/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
onApplyfor 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
waitForfor 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.