Webclat / GTM Practice

How do I get GTM to reliably fire a pageview on every route change in a React/Vue single-page app?

Quick answer

GTM's default History Change trigger fires on browser navigation events, but many SPA routers do not call those APIs the way GTM expects, or fire transitions GTM should not count as a real page view. The reliable pattern is to have the router itself push a virtualPageview event with the new path to dataLayer on every navigation, and fire your pageview tag on that custom event instead of trusting History Change alone.

Why this happens

GTM's History Change trigger works by monkey-patching history.pushState/replaceState and listening for popstate/hashchange. Most modern routers do go through these browser APIs, so History Change often half-works - but it fires on every history entry, including ones your app does not consider a real page view (a modal opening that updates a query param, a tab switch using replaceState), and it can double-fire or miss transitions depending on router version and load order. Relying on it alone produces noisy or incomplete pageview counts rather than a clean match with real navigations.

Fix it

  1. In your app's router, add a navigation listener (React Router's useLocation() + useEffect; Vue Router's router.afterEach; Next.js App Router's usePathname() in a client component) that fires on every completed route change.
  2. In that listener, push a custom event with the new path: window.dataLayer.push({event:"virtualPageview", page_path: pathname, page_title: document.title});
  3. In GTM, create a Custom Event trigger for virtualPageview, turn off "Send a page_view event when this configuration loads" on the GA4 Configuration tag, and add a separate GA4 Event tag with event name page_view reading the pushed Data Layer Variables, triggered on virtualPageview.
  4. Fire the very first page view either from the same listener running once on mount, or from the GA4 Configuration tag's automatic page_view - pick one, not both, to avoid double-counting the initial load.
  5. Remove or scope down any built-in History Change trigger firing the same pageview tag, so you have exactly one trigger source per pageview tag, not two competing ones.

How to verify it worked

Open GTM Preview, navigate between two routes without a full page reload, and confirm exactly one virtualPageview event appears in the Preview timeline per navigation - not zero, not two. Check the GA4 tag's fired parameters show the new page_path, and confirm in GA4 DebugView that each navigation produces one page_view event with the correct path, not the previous route's path.

Still stuck after working through this?

Send us what you are seeing in Preview and the Network tab. We trace GTM containers for a living and can usually tell you what is actually happening in one look.

Ask a GTM Engineer