You added the GA4 snippet to your React app, deployed it, opened the Realtime report and saw exactly one page view. Then you clicked through five routes and… nothing. Welcome to the most common problem with Google Analytics in a React single page application: GA4 measures document loads, and a SPA only loads the document once.
This tutorial shows you exactly why that happens and how to fix it properly with gtag.js, React Router and a reusable usePageTracking hook. We also cover the two things most tutorials skip: debugging with DebugView and avoiding duplicate page_view counts that silently inflate your traffic numbers.
Why GA4 misses page views in a React SPA
How gtag.js behaves on a classic website
On a traditional multi-page website, every navigation triggers a full browser document load. The GA4 snippet is executed again, the config command runs, and gtag.js automatically sends a page_view event. One document load equals one page view. Nothing else to do.
What breaks in React
A React SPA served by React Router uses the History API (pushState / replaceState). The URL in the address bar changes, the component tree re-renders, but:
- the document is never reloaded,
- the GA4 snippet is never executed again,
- no automatic
page_viewis sent after the very first load.
Result: GA4 attributes 100% of the session to your landing route, your bounce rate looks artificially perfect, engagement time is glued to a single page, and funnel or path reports are useless. For the wider picture, see Track Single Page Applications with GTM and GA4.
Isn’t enhanced measurement supposed to handle this?
GA4 data streams ship with an enhanced measurement option called “Page changes based on browser history events”. It listens to History API changes and fires a page_view automatically. It works, but it is not reliable enough for a production React app:
- It fires before React has updated
document.title, so your page titles are wrong (usually the previous page’s title). - It cannot wait for data loaders, suspense boundaries or auth redirects to finish.
- It fires on
replaceStatecalls too, which many routers and filter UIs use for query-string updates, generating noise. - It gives you zero control over extra parameters (route pattern, logged-in state, content group).
- And the classic trap: if you leave it ON and you also send manual events, every navigation is counted twice.
The clean approach for React is: turn the automatic behaviour off and send page_view yourself, at the exact moment you decide.

Step 1: prepare the GA4 property and data stream
- In Google Analytics, go to Admin > Data collection and modification > Data streams and open (or create) your Web stream.
- Copy the Measurement ID (format
G-XXXXXXXXXX). - Click Enhanced measurement > the gear icon.
- Expand Page views and uncheck “Page changes based on browser history events”. Leave the main “Page views” toggle ON (it covers the initial document load if you ever re-enable it).
- Save.
Why this matters: this single checkbox is responsible for most “my GA4 numbers are doubled” tickets in React projects.
Step 2: install gtag.js with send_page_view disabled
Add the snippet to index.html (Vite, CRA) or to your root document. The key is the send_page_view: false flag: it tells gtag.js “load, create the session, but let me decide when a page view happens”.
<!-- index.html -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', {
send_page_view: false
});
</script>
If you prefer to keep the ID out of your HTML, load the script dynamically from React using an environment variable. With Vite:
// src/analytics/loadGtag.ts
export function loadGtag(measurementId: string) {
if (!measurementId || document.getElementById('ga4-script')) return;
const script = document.createElement('script');
script.id = 'ga4-script';
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag() { window.dataLayer.push(arguments); };
window.gtag('js', new Date());
window.gtag('config', measurementId, { send_page_view: false });
}
Store the ID in .env as VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX (or REACT_APP_GA_MEASUREMENT_ID with Create React App).

Step 3: create a tiny typed analytics wrapper
Never call window.gtag directly from your components. A thin wrapper gives you TypeScript safety, a single place to add consent checks, and the ability to no-op in development.
// src/analytics/index.ts
declare global {
interface Window {
dataLayer: unknown[];
gtag?: (...args: unknown[]) => void;
}
}
export const GA_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined;
const ENABLED = Boolean(GA_ID) && import.meta.env.PROD;
export function pageview(path: string, title?: string) {
if (!ENABLED || !window.gtag) return;
window.gtag('event', 'page_view', {
page_title: title ?? document.title,
page_location: window.location.origin + path,
page_path: path,
send_to: GA_ID
});
}
export function trackEvent(name: string, params: Record<string, unknown> = {}) {
if (!ENABLED || !window.gtag) return;
window.gtag('event', name, params);
}
export {};
Which parameters should you send?
| Parameter | Required? | Notes |
|---|---|---|
page_location |
Yes | Full URL including origin. If omitted, GA4 keeps the URL of the initial load. |
page_title |
Strongly recommended | Must be read after React updated the title, otherwise reports show the previous page. |
page_path |
Optional | Not a native GA4 dimension but handy as a custom dimension. |
page_referrer |
Optional | Set it to the previous in-app URL to rebuild real navigation paths. |
send_to |
Optional | Useful when several destinations (Ads, second GA4 property) share the page. |
Step 4: the usePageTracking custom hook
This is the heart of the setup. The hook listens to React Router location changes and fires exactly one page_view per real navigation.
// src/analytics/usePageTracking.ts
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom'; // 'react-router' in v7
import { pageview } from './index';
export function usePageTracking() {
const location = useLocation();
const lastTrackedRef = useRef<string | null>(null);
useEffect(() => {
const path = location.pathname + location.search;
// Guard against React 18/19 StrictMode double effects and re-renders
if (lastTrackedRef.current === path) return;
lastTrackedRef.current = path;
// Let React commit the new document.title before reading it
const timer = window.setTimeout(() => {
pageview(path);
}, 0);
return () => window.clearTimeout(timer);
}, [location.pathname, location.search]);
}
Three details that make this hook production ready
- The ref guard prevents the double fire caused by
<StrictMode>in development and by any parent re-render. - The 0 ms timeout pushes the call to the next macrotask, after React has painted and after libraries like
react-helmet-asyncor the React 19 native<title>tag have updated the document title. - Depending on
pathnameandsearchonly (not the wholelocationobject) avoids extra fires when onlylocation.keyorstatechanges.
If your query string contains filters that you do not want to treat as separate pages (pagination, sort order, UTM leftovers), drop location.search from the path and the dependency array. freecodecamp.org walks through the specifics.

Step 5: mount the hook in your router
The hook must live inside the Router context. The cleanest place is a layout route or a small wrapper component.
React Router v6 / v7 with a layout route
// src/routes/RootLayout.tsx
import { Outlet } from 'react-router-dom';
import { usePageTracking } from '../analytics/usePageTracking';
export default function RootLayout() {
usePageTracking();
return (
<>
<Header />
<Outlet />
<Footer />
</>
);
}
// src/main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import RootLayout from './routes/RootLayout';
const router = createBrowserRouter([
{
element: <RootLayout />,
children: [
{ path: '/', element: <Home /> },
{ path: '/pricing', element: <Pricing /> },
{ path: '/blog/:slug', element: <Article /> }
]
}
]);
export default function App() {
return <RouterProvider router={router} />;
}
Legacy setup with BrowserRouter
function AnalyticsListener() {
usePageTracking();
return null;
}
export default function App() {
return (
<BrowserRouter>
<AnalyticsListener />
<Routes>{/* ... */}</Routes>
</BrowserRouter>
);
}
Bonus: send the route pattern, not just the URL
URLs like /orders/8471 explode into thousands of unique rows in your reports. Send the matched pattern as a custom dimension so you can aggregate:
import { useMatches } from 'react-router-dom';
const matches = useMatches();
const routePattern = matches[matches.length - 1]?.id ?? 'unknown';
// pass it as an extra parameter, e.g. { route_pattern: routePattern }
Avoiding duplicate page_view events
Duplicated page views are worse than missing ones: your numbers look great and every decision built on them is wrong. Here are the five real-world causes and their fixes.
| Cause | Symptom | Fix |
|---|---|---|
| Enhanced measurement history events still ON | 2 page views per route change, one with the wrong title | Uncheck it in the data stream settings (Step 1) |
config without send_page_view: false |
Landing page counted twice, other pages once | Add the flag, or skip the manual event on first render |
| React StrictMode double effect (dev only) | Doubled hits locally, correct in production | The useRef guard in the hook |
| gtag.js snippet and a GA4 tag in Google Tag Manager | Every event duplicated, 2 sessions per user | Pick one implementation, remove the other |
Router redirects (/ to /dashboard, auth guards) |
Ghost page views on intermediate routes | Track after the redirect resolves, or ignore known redirect paths |
Quick audit tip: open the Network tab, filter on /g/collect, and click through your app. You should see exactly one request with en=page_view per navigation.

Debugging with GA4 DebugView
DebugView is the only way to confirm your implementation in minutes instead of waiting 24 to 48 hours for standard reports.
1. Turn on debug mode
Pick one of these three options:
- Install the Google Analytics Debugger Chrome extension and toggle it ON.
- Add
debug_mode: trueto yourconfigcall in non-production builds. - Add
debug_mode: trueto individual events you want to inspect.
gtag('config', 'G-XXXXXXXXXX', {
send_page_view: false,
debug_mode: import.meta.env.DEV
});
2. Open the report
In GA4: Admin > Data display > DebugView. Your device appears in the debug device selector within a few seconds.
3. What to verify, route by route
- One
page_viewper navigation, no more. page_locationmatches the URL in the address bar (full URL, correct query string).page_titleis the new page title, not the previous one. If it lags, increase the timeout in the hook or trigger tracking from your title-setting effect.- Going back and forward in the browser produces page views too (React Router emits location changes for popstate, so the hook covers it).
session_startandfirst_visitfire only once per session.
4. Common “it still does not work” causes
- An ad blocker or a privacy-focused browser blocks
googletagmanager.com. Test in a clean profile. - Your consent banner has not granted
analytics_storageyet, so hits are queued or discarded. - The environment variable is undefined in the production bundle (check the built JS, not the source).
- You are filtering out internal traffic by IP and testing from the office.
- You are looking at standard reports instead of Realtime or DebugView. Aggregated reports need up to 48 hours.
Tracking custom events in your React SPA
Once the wrapper exists, everything else is one line. Use snake_case names and keep parameter names consistent across the app.
import { trackEvent } from '../analytics';
<button
onClick={() => {
trackEvent('sign_up_started', { method: 'email', plan: 'pro' });
startSignup();
}}
>
Create account
</button>
Useful SPA-specific events that no automatic setting can capture for you:
form_submittedwith aform_nameparameter (SPA forms rarely trigger a real submit).modal_openedfor content shown without a URL change.filter_appliedfor faceted search you deliberately excluded from page views.view_item,add_to_cart,purchasefor ecommerce (GA4 recommended events).app_errorfrom your error boundary, with the route pattern attached.
Remember to register custom parameters as custom dimensions in Admin > Custom definitions, otherwise they are collected but not reportable.

Consent Mode v2 and cookie banners
If you have European visitors, set default consent before the GA4 config call, then update it when the user answers your banner. Your manual page_view events do not need to change: gtag.js handles queuing and modelling. The piece Tracking Single Page Applications With Google Analytics makes a good next read.
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
// after the user accepts
gtag('consent', 'update', { analytics_storage: 'granted' });
gtag.js vs GTM vs react-ga4: which approach?
| Approach | Best for | Pros | Cons |
|---|---|---|---|
| gtag.js + custom hook (this guide) | Product teams who own the codebase | Zero dependency, full control of timing, easy to type | Every change requires a deploy |
| Google Tag Manager + dataLayer pushes | Marketing teams managing several tags | Tags editable without a deploy, history change trigger available | Extra script weight, harder debugging, easy to duplicate tags |
react-ga4 package |
Quick prototypes | Slightly less boilerplate | Third-party wrapper around gtag, slower to follow GA4 changes |
If you use Next.js App Router, the same logic applies but the hook uses usePathname() and useSearchParams() inside a component wrapped in <Suspense>, and you load the script with next/script.
Final implementation checklist
- Measurement ID stored in an environment variable, not hardcoded.
- gtag.js loaded once, with
send_page_view: false. - Enhanced measurement “Page changes based on browser history events” disabled.
usePageTrackingmounted inside the Router, in a single layout component.- Ref guard in place for StrictMode and re-renders.
page_locationandpage_titleverified in DebugView on at least five routes.- Back and forward browser buttons tested.
- Network tab shows one
/g/collectcall withen=page_viewper navigation. - Consent Mode defaults set before the config call.
- Custom parameters registered as custom dimensions in GA4.
With that in place, Google Analytics in your React single page application behaves exactly like on a classic website: accurate page views, correct titles, usable path exploration and engagement metrics you can actually trust.
FAQ
Does GA4 track React route changes automatically?
Only if the enhanced measurement option “Page changes based on browser history events” is enabled, and even then the page titles are frequently wrong because the event fires before React updates the document. Manual page_view events are the reliable method.
Why does my GA4 report show only the home page?
Because your SPA never reloads the document, so gtag.js sends a single automatic page view on the first load. Every subsequent client-side navigation needs a manual event.
Why are my page views doubled?
The most common reasons are the history events setting left enabled alongside manual tracking, a GTM GA4 tag installed next to the gtag snippet, or React StrictMode running effects twice in development. Check the causes table above.
Should I use react-ga4 or plain gtag.js?
Plain gtag.js with a small typed wrapper is enough for most projects, keeps your dependency tree clean and follows GA4 feature updates immediately. A wrapper library only saves a handful of lines.
Does this work with HashRouter?
Yes. Use location.pathname + location.hash when building the path and make sure page_location contains the full URL including the hash fragment.
How do I test GA4 without polluting production data?
Use a separate GA4 property or data stream for staging, enable debug_mode outside production, and define an internal traffic filter in Admin > Data streams > Configure tag settings so your own visits are excluded.
How long before data appears in reports?
DebugView and Realtime show events within seconds. Standard reports and explorations can take 24 to 48 hours to be fully processed, so never conclude that your implementation is broken based on empty standard reports.

