← All articles

Blog

SPA Conversion Tracking: Why Your Tags Break in React, Next.js, and Vue

spa conversion trackingreact conversion trackingsingle page app trackingnext.js conversion tracking

"The tag works on every other page — but on the thank-you screen, nothing fires." If your site is built with React, Next.js, Vue, or any other SPA framework, this is one of the most common conversion tracking failures. And it's invisible in your tag management preview because the preview works differently from a real user's navigation.

The root cause is simple: SPAs don't do real page loads. The browser loads the shell once, and every subsequent "page" is a JavaScript-driven DOM swap. Tags that rely on page load — which is most of them — never see the navigation happen.

This guide explains why SPA tracking breaks, how to fix it for each major framework, and how to verify it's working in production.

1. Why traditional tag firing doesn't work in SPAs

In a traditional multi-page site, every page navigation triggers a full browser page load. The tag management system (GTM, hardcoded gtag, Meta Pixel, etc.) fires on each DOMContentLoaded or window.onload event. A tag configured to fire on "All Pages" literally fires on all pages.

In an SPA:

Multi-page site SPA
Click a link → browser requests new HTML → full page load Click a link → JavaScript swaps the DOM → URL changes via History API
Every page triggers DOMContentLoaded DOMContentLoaded fires once (initial load only)
GTM "All Pages" trigger fires on every navigation GTM "All Pages" trigger fires only on the first load
Conversion tag on /thank-you fires when the page loads Conversion tag on /thank-you never fires (no page load occurs)

This means a conversion tag set up to fire "when the thank-you page loads" fires exactly zero times — because the thank-you page never loads in the browser's sense. The user navigates there, the URL changes, the content appears, but no page load event occurs.

2. The three approaches to fix it

Approach A: Push a virtual pageview to the data layer

The most reliable method for GTM-based setups. When the SPA route changes, push an event to the data layer. GTM triggers fire on that event instead of the built-in "Page View" trigger.

// On every route change, push to dataLayer
dataLayer.push({
  event: 'virtual_pageview',
  page_path: '/thank-you',
  page_title: 'Thank You'
});

In GTM:

  1. Create a Custom Event trigger with event name virtual_pageview
  2. Set your conversion tags to fire on this trigger, with a condition on page_path (e.g., page_path equals /thank-you)

This approach separates the "when to fire" decision from the framework — your app code pushes the event, GTM handles the tag logic.

Approach B: Use the History Change trigger in GTM

GTM has a built-in "History Change" trigger that fires whenever the browser's History API is used (which is how SPAs change the URL). This can work without any code changes to your app.

Setup:

  1. In GTM, create a trigger of type History Change
  2. Add conditions based on {{Page Path}} (e.g., Page Path equals /thank-you)
  3. Attach your conversion tags to this trigger

Caveat: The History Change trigger fires on every URL change, including back/forward navigation. Without careful conditions, your conversion tag can fire when a user navigates back through the thank-you page without actually converting again. This is a common source of duplicate conversions in SPAs. If your GTM tags aren't firing at all, see why GTM tags sometimes don't track for broader troubleshooting.

Approach C: Fire the tag directly in your app code

For hardcoded tags (not using GTM), call the tracking function directly in your component or route handler.

// React: fire in useEffect when the component mounts
useEffect(() => {
  gtag('event', 'conversion', {
    'send_to': 'AW-XXXXXXX/YYYYYYY',
    'value': 100,
    'currency': 'USD'
  });
}, []);
// Next.js App Router: fire in a client component on the thank-you page
'use client'
import { useEffect } from 'react'

export default function ThankYou() {
  useEffect(() => {
    gtag('event', 'conversion', {
      'send_to': 'AW-XXXXXXX/YYYYYYY',
      'value': 100,
      'currency': 'USD'
    });
  }, []);

  return <h1>Thank you for your purchase</h1>
}

Advantage: No GTM complexity; the conversion fires exactly when and where you want it.

Risk: Mixing tracking code into application code can make it harder to manage. If you have many tags across many pages, GTM is usually more maintainable.

GA4's built-in SPA support (Enhanced Measurement)

Before building custom SPA tracking, check whether GA4's built-in feature already covers your needs. GA4 has a "Page changes based on browser history events" option under Enhanced Measurement:

To enable: Admin → Data Streams → Web → Enhanced Measurement → Page Views → Show advanced settings → Enable "Page changes based on browser history events."

When enabled, GA4 automatically fires a page_view event whenever the browser's History API updates the URL — exactly what happens in SPAs. This works without any code changes or GTM configuration.

Critical warning: don't double-count. If you use GTM to send page_view events on route changes AND have this GA4 setting enabled, every SPA navigation gets counted twice. Pick one: either GA4's Enhanced Measurement handles pageviews automatically, or your GTM setup does — not both. This is one of the most common SPA tracking misconfigurations.

Limitation: This handles pageview tracking, not conversion event tracking. You still need to fire conversion-specific events (purchase, sign_up, etc.) via GTM or app code at the right moment.

3. Framework-specific gotchas

React (Create React App, Vite)

  • The problem: React Router changes the URL via the History API. No page load fires.
  • Fix: Use useEffect in the destination component, or listen to route changes in your router and push to the data layer.
  • Watch out: useEffect runs after render, which means the conversion fires slightly after the DOM update. This is usually fine, but if the user navigates away very quickly (e.g., an instant redirect), the tag may not complete.

Next.js

  • App Router: Pages can be server-rendered (RSC) or client-rendered. Conversion tags must run on the client. Use a 'use client' component with useEffect, or use the next/script component with afterInteractive strategy.
  • Pages Router: Use the router.events API to listen for routeChangeComplete and push to the data layer.
  • Static export vs. server: If you're using output: 'export', the behavior is similar to a standard SPA. If you're using server-side rendering, initial page loads fire tags normally — only client-side navigations are affected.
  • Watch out: Next.js prefetches links. Prefetching doesn't trigger a route change, but if your tag fires on the wrong event, prefetch-related DOM activity can cause phantom fires.

Vue (Nuxt)

  • Vue Router: Listen to router.afterEach() to push virtual pageviews to the data layer.
  • Nuxt: Use the page:finish hook or a plugin that fires on route changes.
  • Watch out: Vue's transition system can delay DOM updates. If your tag checks for a DOM element on the destination page, it may not exist yet when the route change event fires.

Angular

  • Angular Router: Subscribe to Router.events and filter for NavigationEnd events. Push to the data layer on each NavigationEnd.
  • Watch out: Angular's Zone.js can interfere with third-party scripts. If a tag loads asynchronously and runs outside Angular's zone, it may behave unpredictably.

4. The duplicate conversion trap

SPAs make duplicate conversions more likely than multi-page sites, for two reasons:

  1. History navigation: A user completes a purchase, lands on /thank-you, then presses the back button and forward button — the History Change trigger fires again, and the conversion is counted twice.

  2. Component re-mounting: In React, if the thank-you component unmounts and remounts (due to a parent re-render or a route flicker), useEffect runs again and fires the tag a second time.

Fixes:

  • Use a one-time flag: set a variable (session storage, a React ref, or a data layer variable) after the first fire, and check it before firing again
  • In GTM, use a "trigger fires once per page" option — but note that in an SPA, "per page" means per initial page load, not per route change
  • Deduplicate on the platform side: use the transaction_id parameter (Google Ads) or eventID (Meta) to let the platform ignore duplicate signals

5. Data layer hygiene between SPA navigations

In a multi-page site, every navigation resets the data layer — it starts fresh on each page load. In an SPA, the data layer persists across route changes. This creates a subtle but damaging problem: stale values from a previous page contaminate events on the current page.

How stale values cause wrong conversion data

A user views Product A ($199), adds it to cart, then navigates to Product B ($49) and purchases. If the data layer still holds Product A's values when the purchase event fires, the conversion may report $199 instead of $49 — or worse, include Product A's item data alongside Product B's.

The fix: flush before each virtual pageview

Reset relevant data layer variables on every route change, before pushing the new pageview:

// On route change — flush, then push
dataLayer.push({
  event: 'spa_cleanup',
  ecommerce: null  // clears GA4 ecommerce data
});

dataLayer.push({
  event: 'spa_pageview',
  spa_page_path: newPath,
  spa_page_title: newTitle
});

Setting ecommerce: null is specifically recommended by Google to clear stale ecommerce data between events (see our guide on GA4 ecommerce purchase events for the full data layer structure). For non-ecommerce variables, explicitly set them to undefined or push a new object that overwrites the previous values.

What to watch for

  • Transaction ID from a previous purchase persisting — causes the next event to carry an old transaction ID, which may deduplicate a real new conversion
  • Currency mismatch — a user browsing a USD product then switching to a JPY product; if currency isn't re-pushed, the old value persists
  • GTM Data Layer Variable version — Version 1 variables persist until explicitly cleared; Version 2 variables are event-scoped but can still leak if read at the wrong timing

6. How to verify SPA conversion tracking

Step 1: Check with DevTools Network tab

Navigate to the conversion page within the SPA (don't reload — navigate from another page). In the Network tab, filter for the conversion request:

  • Google Ads: googleadservices.com/pagead/conversion/
  • GA4: google-analytics.com/g/collect
  • Meta: facebook.com/tr

If the request appears after navigation, the tag fires correctly. If it only appears on a full page reload, the tag is set up for page load only and isn't catching SPA navigation.

Step 2: Test the back-button scenario

After triggering the conversion, press the back button, then the forward button. Check the Network tab again — did the conversion request fire a second time? If yes, you have a duplicate conversion problem.

Step 3: GTM Preview mode limitations

GTM Preview mode can be unreliable for SPA testing. The Preview panel sometimes shows tags as "fired" even when the actual network request didn't go out, or it may not properly reflect History Change events. Always cross-check with the Network tab, not just GTM Preview.

Step 4: Test in a clean environment

SPAs can behave differently depending on:

  • Whether the page was reached by direct URL entry (full page load) vs. internal navigation (SPA route change)
  • Whether service workers are caching the page
  • Whether the user has ad blockers that interfere with the tag

Test both paths: a direct URL load of the thank-you page, and a navigation to it from within the app.

7. Server-side tagging for SPAs

SPAs are particularly vulnerable to client-side tracking failures: ad blockers intercept requests to googleadservices.com, facebook.com/tr, and other tracking endpoints; consent management platforms may block tags before they fire; and heavy client-side JavaScript can delay or prevent tag execution.

Server-side Google Tag Manager (sGTM) addresses these issues by routing tracking requests through your own domain:

Issue Client-side only With server-side GTM
Ad blocker blocking conversion requests Request blocked, conversion lost Request goes to your domain first, then forwarded server-side — not blocked
Consent Mode v2 in SPAs Consent state must be re-checked on each virtual pageview (no page reload to re-initialize) Server-side container can enforce consent state consistently
Tag execution speed Multiple third-party scripts load on the client, slowing the SPA Only one request to your server; server handles fan-out to multiple platforms

When to consider server-side tagging for your SPA:

  • Ad blocker rates among your audience exceed 20–30%
  • You're seeing a large gap between server-side order counts and reported conversions
  • You need to comply with EU privacy regulations and want tighter control over what data leaves the browser

Practical note: Server-side GTM doesn't replace client-side SPA tracking — it complements it. You still need to fire the right events at the right time in your SPA code; server-side tagging changes where those events are processed, not when they fire.

8. GTM configuration for SPAs: a working example

Here's a complete GTM setup for tracking conversions in an SPA:

Data layer push (in your app code):

// Fire this on each route change
function onRouteChange(path, title) {
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    event: 'spa_pageview',
    spa_page_path: path,
    spa_page_title: title
  });
}

GTM trigger:

  • Type: Custom Event
  • Event name: spa_pageview
  • This trigger fires on: Some Custom Events
  • Condition: spa_page_path equals /thank-you

GTM tag:

  • Type: Google Ads Conversion Tracking (or GA4 Event, Meta Pixel, etc.)
  • Trigger: the SPA conversion trigger above

This keeps the tracking logic in GTM (easy to manage) while relying on your app to signal when navigations occur (reliable in SPAs).

Frequently asked questions

Q. Do I need to change my GTM setup if I migrate from a multi-page site to an SPA? A. Yes. Any tag that fires on the built-in "All Pages" or "Page View" trigger will stop firing on SPA navigations. You need to add History Change triggers or data layer event triggers for every tag that should fire on route changes.

Q. My form submission conversion doesn't track in my SPA. What's different? A. In SPAs, form submissions often happen via JavaScript (AJAX/fetch) without any page navigation, so neither a page load trigger nor a History Change trigger will catch them. You need to fire the conversion event directly in the form's success handler or push a data layer event when the submission completes. For more on this pattern, see why form submissions aren't tracking.

Q. My conversion tag fires on full page reload but not on SPA navigation. Is there a quick fix? A. The quickest fix is the GTM History Change trigger — no code changes needed. But for reliability, pushing a custom event to the data layer from your app code is better, because you control exactly when the event fires.

Q. Will Google's gtag.js handle SPA navigation automatically? A. The Google tag (gtag.js) does have some SPA support — it can detect History API changes and send pageview events. But conversion events (like gtag('event', 'conversion', ...)) still need to be explicitly called at the right moment. Don't rely on automatic detection for conversion tracking.

Q. We use server-side rendering (SSR). Does this still apply? A. Partially. The initial page load is a real page load (SSR sends full HTML), so tags fire normally on the first hit. But subsequent navigations within the app are still client-side SPA navigations — those need the SPA treatment. In Next.js, this means the first page load works fine, but clicking a <Link> to another page is an SPA transition.

Conclusion: the tag isn't broken — it just never saw the page load

SPA conversion tracking failures aren't caused by broken tags. The tags are fine; they're just listening for an event (page load) that never happens after the initial load. The fix is always the same pattern: tell the tag when a navigation occurred, either via a data layer push, a History Change listener, or a direct function call in your component code.

The more subtle problem is verification. Use our conversion tracking verification checklist as a starting point, but add SPA-specific checks. Because SPAs behave differently depending on how the user arrived at the page, a conversion can work in one scenario and fail in another — and GTM Preview doesn't always catch the difference. Always verify with the Network tab, and always test both direct URL access and in-app navigation.

ConversionOK tests your live pages in an independent browser and checks whether the conversion signals are actually sent, regardless of how the page was built. For SPAs, this catches the cases where a tag is installed but never fires on the conversion page — a problem that's invisible until you look at the network level.