Reference

Single-Page Apps

Split Test Pro applies experiments on the initial page load; SPA route changes re-fire page-view goals but not targeting. Learn the limitation and workarounds.

Advanced5 min read

If your site is a single-page application (SPA) — React, Vue, Svelte, Angular, Nuxt, Next.js, Astro client islands — there’s an important limitation to know about. This guide covers what’s affected, why, and what to do about it.

The Limitation

Split Test Pro’s HTML script binds variants to the page on initial load:

  1. The page loads.
  2. The script runs, fetches active experiments, evaluates URL targeting against window.location, and applies any matching variants.
  3. If the visitor navigates within the SPA via client-side routing (history.pushState, React Router, Vue Router, etc.), targeting is not re-evaluated and no new variants are applied.

Concretely:

  • A variant on /products/widget activates correctly when the visitor lands there directly.
  • The same visitor clicking a link to /products/widget-v2 (without a full page reload) doesn’t trigger a fresh targeting evaluation.
  • If /products/widget-v2 has a different active experiment, that experiment’s variant won’t apply.
  • The variant from /products/widget may still be applied to the new route (the CSS / JS injection persists in the DOM).

The script does watch route changes for measurement. On pushState, replaceState, popstate and hashchange it re-fires page-view goals for the new URL and, when site-wide tracking is on, records a site-wide page view. Experiment targeting and variants still bind on the initial load only.

This affects the HTML platform. The Shopify integration is not affected the same way — Shopify storefronts are mostly server-rendered, and full-page navigations are the norm.

Why It Works This Way

A few honest reasons:

  • Compatibility — different SPA frameworks dispatch different events; there’s no single hook that works everywhere.
  • Predictability — re-running variant assignment mid-session could change which variant a visitor sees, which is a worse experience than not running at all.

The workarounds below cover the common cases.

The Right Workaround Depends on Your Setup

Different SPA architectures need different approaches.

Approach 1: Force a full page reload on key navigations

The simplest fix. If your SPA can fall back to full-page navigation for certain links (e.g., links between major sections), the script re-evaluates on the load.

In React Router:

{/* For most internal links, use Link as normal */}
<Link to="/about">About</Link>

{/* For links to pages with experiments, use a regular anchor: */}
<a href="/products/widget">Widget</a>

In Next.js:

{/* Most pages: */}
<Link href="/about">About</Link>

{/* Pages with experiments: bypass the client router */}
<a href="/products/widget">Widget</a>

Trade-off: full-page navigation is slower than client routing. Use this approach only if the affected pages are infrequent destinations or the slowdown is acceptable.

Approach 2: Reload programmatically on SPA route change

If you can’t avoid client-side routing, force a reload when entering a route with an experiment.

React Router v6:

import { useEffect } from "react";
import { useLocation } from "react-router-dom";

function ExperimentRouteWrapper({ children }) {
  const location = useLocation();
  useEffect(() => {
    // Force full reload if this route is configured for experiments
    if (location.pathname.startsWith("/products/")) {
      window.location.reload();
    }
  }, [location.pathname]);
  return children;
}

Wrap your experiment-eligible routes in this component. The first SPA navigation in triggers a reload, after which you’re effectively on a regular page-load cycle.

Vue Router:

router.afterEach((to, from) => {
  if (to.path.startsWith("/products/")) {
    window.location.reload();
  }
});

Approach 3: Re-running the script’s targeting (not supported)

The HTML script has no public re-init API, so there is no supported way to re-run its targeting pass on a route change without a reload. The cleanest path is Approach 1 or 2.

Approach 4: Treat each SPA “page” as a distinct experiment surface

Rather than fighting the SPA’s behavior, design your experiments around it:

  • Run experiments on the landing page of a flow only — the page where visitors first arrive via direct URL or search. SPA navigations after that are “in-flow” and don’t get experiment-affected.
  • Use event activation to trigger variants on specific user interactions rather than on page load. The activation fires regardless of routing.
  • For tests that need to span multiple SPA routes, use a CSS variant in the global app shell that targets elements via class or data attribute — the variant applies once and persists across route changes.

This isn’t a workaround so much as a reframing: SPAs and per-page A/B testing have a fundamental impedance mismatch, and designing around it produces cleaner results than retrofitting.

What About Events?

The activation/targeting limitation doesn’t affect conversion tracking. SplitTestPro.trackConversion(eventKey) works fine in an SPA context — it reads the visitor’s current cookie assignment and posts the event. As long as the visitor was assigned at some point during the session (on initial load or after a forced reload), tracking events later in the SPA flow attributes correctly.

The limitation is purely about which experiments apply to which routes. The data model handles cross-route conversion correctly.

Common Setup Issues in SPAs

A few SPA-specific gotchas:

Variant CSS gets re-rendered out

Some frameworks (especially Vue with scoped styles) re-render the DOM aggressively on route changes, which can wipe inline styles applied by the variant. If your variant works briefly and then disappears, see Troubleshooting “Variant CSS shows briefly, then disappears.”

Variant JS runs against an empty DOM

If your variant JS targets an element that’s rendered by the SPA framework asynchronously, the JS may run before the element exists. Use a MutationObserver pattern — see JavaScript Variants.

The script loads but never sees a route match

If your SPA uses hash-based routing (/#products/widget) instead of pushState routing (/products/widget), the script’s URL targeting evaluates against the hash. Fragment rules exist — the Hash attribute (see URL Targeting) — but targeting still runs only on the initial load, so hash-router sites should prefer Page URL rules over fragment-specific ones.

Frameworks Tested

The HTML script works with:

  • Next.js (both app/ and pages/ routers, both server and client components)
  • React Router v6
  • Vue Router v4
  • Astro with React/Vue/Svelte islands
  • Nuxt 3
  • SvelteKit
  • Any framework that produces a page with a <head> and a real URL

The key is that on initial pageload, the script runs synchronously and applies variants. The SPA’s subsequent behavior is up to the framework.

When This Limitation Doesn’t Matter

If your SPA mostly:

  • Receives traffic from search / email / direct URL (so visitors enter via initial pageload).
  • Has experiments only on landing pages, not deep-linked internal flows.
  • Treats route changes as in-flow continuations rather than distinct experiment surfaces.

…then the limitation effectively doesn’t apply. Most SaaS sites fit this pattern — landing pages are where experiments matter, and post-signup flows are not typically experiment surfaces.

If your SPA has experiments throughout an extended in-app journey (e.g., a multi-step onboarding), the workarounds above become essential.

Next Steps

Ready to ship a winner?
Start free today.

Sign up free in under a minute — no credit card, no installation steps until you're ready.

  • No credit card
  • 14-day free trial