Tracking Page Views in a React SPA with Google Analytics 4
Source: Dev.to

Adding Google Analytics (GA4) to a standard HTML website is straightforward: paste the tracking snippet into your <head> and you’re done. Every time a user clicks a link, the browser fetches a new HTML page, and GA registers a page view.
But if you are building a Single Page Application (SPA) with React, Vite, and React Router, this out‑of‑the‑box behavior breaks down.
In a React SPA, clicking a link doesn’t trigger a page reload. React simply unmounts the old component and mounts the new one while manipulating the browser’s URL history. Because the page never actually reloads, Google Analytics never registers the new URL, and your analytics will show users seemingly stuck on the homepage forever.
Below is a step‑by‑step solution that I used for my portfolio site.
1. The Environment Setup
Avoid hard‑coding your tracking ID into source code. Add your Measurement ID (found in the GA dashboard, usually starting with G-XXXXXXXXXX) to a .env file:
VITE_GA_TRACKING_ID=G-**********
2. The Initial HTML Snippet (Modified)
Include the base Google Analytics tracking code in index.html, but disable the automatic page_view tracking to prevent double‑counting.
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
// IMPORTANT: Disable the default page_view tracking here!
gtag('config', '%VITE_GA_TRACKING_ID%', { send_page_view: false });
</script>
Note on Vite: %VITE_GA_TRACKING_ID% injects the environment variable at build time.
3. Creating the Route Listener Component
Create a lightweight, invisible component that listens to URL changes using useLocation from react-router-dom.
src/components/Analytics.tsx
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
// Extend window object for TypeScript
declare global {
interface Window {
gtag: (...args: any[]) => void;
}
}
export const Analytics = () => {
const location = useLocation();
useEffect(() => {
const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_TRACKING_ID;
if (GA_MEASUREMENT_ID && typeof window.gtag === 'function') {
window.gtag('config', GA_MEASUREMENT_ID, {
page_path: location.pathname + location.search,
});
}
}, [location]);
return null; // Invisible component
};
4. Wiring it up to the Router
Mount <Analytics /> inside <BrowserRouter> but outside the <Routes> block.
src/index.tsx
import { Analytics } from './components/Analytics';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import { Suspense } from 'react';
import ScrollToTop from './components/ScrollToTop';
import LoadingFallback from './components/LoadingFallback';
import HomePage from './pages/HomePage';
import AboutPage from './pages/AboutPage';
// ...other imports
const App = () => {
return (
<HelmetProvider>
<BrowserRouter>
{/* Place the listener here! */}
<Analytics />
<ScrollToTop />
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
{/* ... other routes */}
</Routes>
</Suspense>
</BrowserRouter>
</HelmetProvider>
);
};
export default App;
5. The Result
- When a user lands on
vicentereyes.org, the GA script loads. - React boots, mounts the router, and the
<Analytics />component fires a page‑view event for/. - Subsequent navigation (e.g., clicking “Projects”) updates the URL, triggers the
useEffectin<Analytics />, and sends a clean page‑view event for/projects.
This provides accurate SPA tracking without heavy external libraries.