Pixel Tracking in Single-Page Apps: Fix Missing Page Views

Quick fix: In a single-page app the pixel base code runs once, so only the first page view is tracked unless something listens for route changes. Pick exactly one owner for page views on each platform: a GTM History Change trigger or a hook in your router. Then turn off any automatic history tracking that would send the same page view a second time.
Single-page apps built with React, Next.js, Vue, Angular or Svelte change screens without loading a new document. The router updates the URL with the browser's History API and swaps the content in place. Pixels were designed around full page loads, so they see the first page and then go quiet.
The fix is not just "fire PageView on every route change." Some tags already do that on their own, and adding your own call doubles the count. This guide shows how to find out what each tag does today, choose one mechanism, and verify the result.
Why single-page apps break page tracking
On a classic website, every click loads a new HTML document. The base code runs again, fires a PageView, and any tag on a "Page View" or "All Pages" trigger in Google Tag Manager runs again too.
In a single-page app, the document loads once. Later navigation calls history.pushState() (or replaceState()), and the back and forward buttons trigger a popstate event. No new document means:
- Base code snippets do not re-run.
- GTM "Page View", "DOM Ready" and "Window Loaded" triggers do not fire again.
- Events tied to specific URLs, such as a thank-you page conversion, may never fire if the visitor reaches that URL through client-side navigation.
There are two opposite failure modes. Undercounting: only the landing page is tracked. Overcounting: two mechanisms each send a page view on every route change. Both are common, and you can have one platform undercounting while another overcounts on the same site.
Step 1: Measure what each tag does today
Before changing anything, watch the actual requests.
- Open DevTools, go to the Network tab and enable Preserve log.
- Filter by the platform's endpoint, for example
facebook.com/trfor Meta,collectfor Google Analytics, or the vendor's domain for others. - Load the site, then click through three or four internal links without reloading.
- Count requests per navigation. Also try the back button and a link that only changes the query string.
Zero requests after navigation means undercounting. Two requests per navigation for the same event means overcounting. See how to check which pixels a website fires if you need help reading those requests.
Step 2: Know the built-in history behavior of each platform
Google Analytics 4
GA4's enhanced measurement collects page_view each time the page loads or the browser history state changes. The history part is controlled by an advanced setting. In GA4, go to Admin > Data streams, open your web stream, open the Enhanced measurement settings and look under page views for Page changes based on browser history events. It listens for pushState, popstate and replaceState.
If that setting is on, GA4 already tracks route changes when it is loaded through the Google tag. Google's own SPA guidance warns that if you configure tags in Tag Manager, you should not also enable automatic history tracking, because that can double-count page views.
Meta Pixel
Meta's single-page application guide says you can set disablePushState to true to stop the pixel from sending PageView events on history state changes, and that doing so is not recommended. In other words, the pixel sends PageView on history changes by default. If you also call fbq('track', 'PageView') in your router, you will likely send two PageViews per navigation. Confirm with the Network tab before adding anything.
Other platforms
Behavior varies by vendor and changes over time, so test instead of assuming. Microsoft Advertising, for example, documents a page_view event with a page_path value that must start with a forward slash for single-page apps. Pinterest notes that its Tag Helper accumulates events as you navigate a single-page site, which makes counts look higher than they are in the extension.
Step 3: Choose one owner for page views
For each platform, page views on route change should come from exactly one source:
- the platform's own automatic history tracking, or
- a GTM History Change trigger, or
- a call in your application's router.
Mixing them is the main cause of doubled page views in single-page apps. If several platforms live in GTM, using GTM for all of them keeps the logic in one place. If tags are hard-coded, the router hook is usually cleaner.
Option A: The GTM History Change trigger
Google Tag Manager's History Change trigger fires when the URL fragment changes or when the site uses the HTML5 pushState API. To set it up:
- Go to Variables > Configure and enable the History built-in variables: History Source, New History Fragment, Old History Fragment, New History State and Old History State.
- Go to Triggers > New > Trigger Configuration and choose History Change.
- Attach the trigger to each page view tag, in addition to its normal page load trigger.
For GA4 in GTM, first turn off Page changes based on browser history events in the data stream. Then send a page_view GA4 Event tag on the History Change trigger, with the page location and title set to the new values. Google's Measure SPAs with Tag Manager guide walks through this, including a tag-sequencing setup that updates the Google tag before the virtual page view fires.
Many apps call replaceState when filters, tabs or scroll positions update the URL, and some frameworks call it during startup. Each call can fire the History Change trigger. If you see extra page views, set the trigger to Some History Changes and filter on History Source, or compare the old and new page path.
Also check the page title. If your framework updates document.title after the URL changes, {{Page Title}} may still hold the previous title when the trigger fires. If preview shows stale titles, push a custom data layer event from your app after the new screen renders instead of relying on the History Change trigger.
Option B: Fire page views from your router
If you control the code, fire page views from a single place that runs after each navigation. Do not put the call inside individual page components, or every re-render risks a duplicate.
React Router
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export function RouteTracker() {
const location = useLocation();
useEffect(() => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'virtual_page_view',
page_path: location.pathname + location.search
});
}, [location.pathname, location.search]);
return null;
}
Next.js App Router
Create a client component that reads usePathname() and useSearchParams() from next/navigation, run the same effect when either changes, and render it once in your root layout. In the Pages Router, subscribe to router.events and fire on routeChangeComplete.
Vue Router
router.afterEach((to, from) => {
if (to.fullPath === from.fullPath) return;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: 'virtual_page_view', page_path: to.fullPath });
});
In GTM, a Custom Event trigger on virtual_page_view then fires your page view tags. Because your app pushes the event after the route resolves, this approach also avoids the stale-title problem.
In development, React's Strict Mode runs effects twice to surface bugs. If you see doubled events only on localhost, test a production build before you start removing code.
Skip the first run when needed
If the base code already sends a PageView on the initial load, your router hook should not send one for the first render. Track whether the hook has run before, or let the router hook own every page view and remove the base code's initial call. Meta recommends keeping the base code's PageView call intact, so for Meta the first option is safer.
Other events in single-page apps
Page views are not the only thing that breaks.
- URL-based conversions. A conversion configured as "URL contains /thank-you" in an ad platform may not register when the visitor reaches that URL client-side. Fire an explicit event instead.
- Stale data layer values. The GTM data layer persists across route changes. Google recommends pushing
{ ecommerce: null }before each new ecommerce event so old items do not leak into the next one. - Events fired on mount. A ViewContent or
view_itemevent fired from a product component can fire again when the component re-renders or remounts. Tie it to the route change, not the render.
If you suspect two copies of the Google tag are involved, read gtag.js and GTM duplicate tags. For Meta-specific doubling, see fixing duplicate Meta Pixel events.
How to verify the fix
- Repeat the Network tab test from Step 1. Each navigation should produce exactly one page view request per platform.
- Test the back and forward buttons, a query-string-only change and a hash change.
- In GTM preview, each navigation should show one History or custom event with the expected tags fired.
- In GA4 DebugView, compare consecutive
page_viewevents and confirmpage_locationchanges each time. - In Meta's Test Events tool, confirm one PageView per screen with the correct URL.
FAQ
Why does my browser extension show PageView only once?
Either the tag does not track history changes, or the extension only inspects the initial load. Confirm with the Network tab while you click through the app. If requests appear per navigation, tracking works.
Should I use the History Change trigger or a custom data layer event?
The History Change trigger needs no code changes, which makes it a quick fix. A custom event pushed from your router is more precise because it fires after the new screen has rendered, with the correct title and data.
Does hash-based routing work with these methods?
Yes. GTM's History Change trigger also fires on URL fragment changes. Check whether your analytics platform records the fragment in the page path, since some strip it by default.
Do I need to re-run the base code on each route?
No. Load each base code once per document. Re-running it can re-initialize the pixel and cause duplicate events. Send only the page view or event call on route changes.