How do I stop GA4 from double-firing page_view (and other events) in a single-page app?
Quick answer
Duplicate page_view (or other) events happen when more than one code path is configured to send the same event for the same navigation - most commonly GA4's automatic page_view left enabled alongside a manually-fired one on route change, or the gtag.js snippet loaded twice (once directly, once again through GTM). The fix is to have exactly one active firing path per event, never two.
Why This Happens
GA4's default behavior is to log a page_view automatically whenever the library initializes on a page - fine for a classic server-rendered site where that only happens once per real page load, but wrong for an SPA where the library initializes once and then the app handles all subsequent navigation client-side without ever reinitializing GA4.
The duplicate almost always traces to one of three setups: (1) send_page_view was never explicitly set to false, so the automatic page_view still fires for the initial load in addition to a manual one from a router hook; (2) the gtag.js snippet is loaded both directly in the HTML and again via Google Tag Manager, so every event fires through two independent tag instances; or (3) a router-change listener (e.g. a useEffect with a dependency array) re-runs more often than intended, such as on every render rather than only on path changes.
Fix It
Audit for two separate GA4 tag instances
Search your codebase and your GTM container for the same Measurement ID. If it's configured both directly (a hardcoded gtag.js snippet) and inside GTM's GA4 Configuration tag, remove one - standardize on a single deployment method.
Explicitly disable the automatic page_view
Set send_page_view: false in your GA4 config call (or, in GTM, uncheck 'Send a page view event when this configuration loads' on the GA4 Configuration tag) so nothing fires automatically.
Fire the manual page_view from exactly one listener
Pick a single source of truth for route changes - your router's own navigation-complete event (e.g. Vue Router's afterEach, Next.js's pathname-change effect) - and make sure no second listener (a GTM trigger and a manual gtag call, for instance) is also wired to the same navigation.
Check the dependency array on any React/Vue effect firing the event
A useEffect(() => { trackPageView() }) with no dependency array (or one that includes a value that changes on every render) will fire on every re-render, not just every navigation - scope the dependency array to only the route path.
How To Verify It Worked
Open GA4 DebugView, navigate to a single route once, and count the page_view events attributed to that navigation in the debug event stream - it should be exactly one, with one matching set of parameters.
As a network-level check, open the browser's Network tab, filter for collect or g/collect, and navigate once - a passing result shows exactly one request for the page_view event; a failing result shows two (or more) nearly-identical requests fired within milliseconds of each other for the same navigation.
Still Not Fixed?
We audit and fix GA4 implementations for a living - if this doesn't resolve it, the next step is usually a full event-by-event audit.
Talk to a GA4 Consultant