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.
Here is exactly how I solved this for my portfolio site.
1. The Environment Setup
First, avoid hardcoding your tracking ID into your source code. If your repo is public, anyone could scrape it. Add your Measurement ID (found in your GA dashboard, usually starting with G-XXXXXXXXXX) to your .env file.
VITE_GA_TRACKING_ID=G-**********
2. The Initial HTML Snippet (Modified)
You still need the base Google Analytics tracking code in your index.html.
However, we need to make one critical modification: we must tell GA4 not to automatically track the initial page view. If we don't disable it, we will end up double-counting the first page load when our React Router listener kicks in later.
In index.html:
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=%VITE_GA_TRACKING_ID%"></script>
<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: I used
%VITE_GA_TRACKING_ID%to inject the environment variable directly into the HTML at build time.
3. Creating the Route Listener Component
Now we need a way to listen to URL changes inside React and ping Google Analytics manually. We can create a lightweight, invisible component to handle this using the useLocation hook from react-router-dom.
Create components/Analytics.tsx:
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
// Extend window object for TypeScript so it doesn't complain about window.gtag
declare global {
interface Window {
gtag: (...args: any[]) => void;
}
}
export const Analytics = () => {
const location = useLocation();
useEffect(() => {
// Read the tracking ID from environment variables
const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_TRACKING_ID;
// Ensure the ID exists and the gtag function was loaded successfully
if (GA_MEASUREMENT_ID && typeof window.gtag === 'function') {
// Ping Google Analytics manually
window.gtag('config', GA_MEASUREMENT_ID, {
page_path: location.pathname + location.search,
});
}
}, [location]); // Re-run this effect every time the URL changes
return null; // This component is invisible
};
4. Wiring it up to the Router
Finally, we need to mount our new <Analytics /> component.
The most important rule here is that Analytics must be placed inside the <BrowserRouter> but outside of the <Routes> block. If it's outside the Router, the useLocation hook will throw a contextual error.
In index.tsx:
import { Analytics } from './components/Analytics';
// ... 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>
);
};
The Result
And that's it!
Now, when a user lands on vicentereyes.org, the Google Analytics script loads.
Then, React boots up, mounts the router, hits the <Analytics /> component, and fires a page view event for /.
When the user clicks "Projects", React Router handles the transition, the URL updates to /projects, the useEffect inside <Analytics /> fires again, and a clean page view event for /projects is pushed to Google. Perfect SPA tracking, no heavy external libraries required.
Top comments (0)