Tracking Page Views in a React SPA with Google Analytics 4

Published: (February 22, 2026 at 09:30 PM EST)
3 min read
Source: Dev.to

Source: Dev.to

Cover image for Tracking Page Views in a React SPA with Google Analytics 4

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 useEffect in <Analytics />, and sends a clean page‑view event for /projects.

This provides accurate SPA tracking without heavy external libraries.

0 views
Back to Blog

Related posts

Read more »

Understanding importmap-rails

Introduction If you've worked with modern JavaScript, you're familiar with ES modules and import statements. Rails apps can use esbuild or vite or bun for this...