---
title: "How to add web analytics to a React app"
description: "Track page views, sessions, and UTMs in a Vite and React Router app, keep campaign data through client-side redirects, and read traffic by channel."
canonical: "https://amplitude.com/en-us/build/react-web-analytics"
---

# How to add web analytics to a React app

Track page views, sessions, and UTMs in a Vite and React Router app, keep campaign data through client-side redirects, and read traffic by channel.

Source: https://amplitude.com/en-us/build/react-web-analytics

---

# How to add web analytics to a React app

Configure the Amplitude Unified SDK so every React Router navigation becomes a page view, every visit carries its UTM and referrer data, and sessions feed bounce rate and session length. Then keep campaign parameters alive through client-side redirects and open the Marketing Analytics views that read them. About 15 minutes, all copy-paste.

- React 18+
- Vite
- React Router
- Unified SDK 1.x
- 15 min

Chris Tam · Last updated 6 October 2026

A React app built with Vite is a single-page app: the browser loads one real page, and React Router swaps everything after that. [Web analytics](https://amplitude.com/web-analytics) tells you where those visitors come from, which campaigns bring them, and which pages keep them.

In this tutorial, you'll configure Amplitude for traffic data, keep campaign parameters through client-side redirects, and set up the channel and campaign views. It builds on the [React product analytics page](/build/react-product-analytics).

## What you'll build

A React app that sends `[Amplitude] Page Viewed` on first load and on every route change, starts and ends sessions automatically, and stores `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, and click IDs as both user properties and event properties, including on visits that land on a URL React Router redirects. By the end you'll see a test campaign in your event stream and a Marketing Analytics view configured to read it.

## Prerequisites

- A Vite project with React 18 or later, TypeScript, and React Router (`react-router-dom`) using `BrowserRouter`. `npm create vite@latest` with the React and TypeScript template is fine. Step 3 covers `HashRouter`.
- An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
- An Admin or Manager role in the Amplitude project for step 5. The Member role can't edit Marketing Analytics settings.

No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Install the SDK

[The Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) recommends `@amplitude/unified` for browser apps installed from npm, and the [React product analytics page](/build/react-product-analytics) uses it, so this setup drops into the same `src/lib/amplitude.ts`. Unified also includes session replay, but it only records when `sessionReplay.sampleRate` is above 0, and this page doesn't set it. If you only want analytics and bundle size matters, `@amplitude/analytics-browser` takes the same `autocapture` options, passed to `init()` instead of inside `initAll()`.

Terminal

```sh
npm install @amplitude/unified
```

Vite only exposes env vars prefixed with `VITE_` to browser code. A project API key is public and its scope is limited to the minimum needed to ingest data, so shipping it to the browser is expected, but keep it out of source anyway.

.env.local

```sh
VITE_AMPLITUDE_API_KEY=your-project-api-key
```

## 2. Configure autocapture for traffic data

Every option web analytics depends on is on by default. Name them anyway, so nobody turns one off later without knowing what it feeds, and change one: `trackHistoryChanges`.

src/lib/amplitude.ts

```typescript
import * as amplitude from '@amplitude/unified';

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

if (apiKey) {
  amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        // UTM, referrer, and click ID capture. Feeds Traffic by Channel and Campaign.
        attribution: true,
        // [Amplitude] Page Viewed on load and on every path change.
        // 'pathOnly' ignores query-string-only changes such as ?sort=price.
        // Using HashRouter? Read step 3 before keeping this line.
        pageViews: { trackHistoryChanges: 'pathOnly' },
        // [Amplitude] Start Session and End Session. Feeds bounce rate and session length.
        sessions: true,
        // Adds current and previous page properties to every event.
        pageUrlEnrichment: true,
        formInteractions: true,
        fileDownloads: true,
      },
    // No sessionReplay block, so no sessions are recorded.
  });
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

export { amplitude };
```

Import it first in your entry file, above everything else:

src/main.tsx

```typescript
import './lib/amplitude';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);
```

**Why.** Attribution reads campaign data from the URL, so init has to start while the landing URL is still intact. Module code runs once, before `createRoot` and before React Router renders a single route. A `useEffect` would run after the first render, by which point a `<Navigate>` may already have replaced the URL. Module scope also sidesteps Strict Mode, which runs effects twice in development but evaluates a module once.

If you followed the [React product analytics page](/build/react-product-analytics), you already have this file. Keep your `elementInteractions: true` and `sessionReplay` lines, and change `pageViews: true` to the object above.

Attribution needs no config. As of Browser SDK 2.45.0, which `@amplitude/unified` 1.1.38 includes, the SDK stores campaign parameters two ways by default: as user properties (including first-touch `initial_utm_source` and friends, set once) and as event properties on every event. For referrer exclusions and the other attribution options, see [Track marketing attribution](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2#track-marketing-attribution).

Clicks (`elementInteractions`) stay off here because no web analytics view reads them. Turn them on if you also want click events in charts.

## 3. Match page views to your router

There's no page view code in this app, and there shouldn't be. With `pageViews` on, the SDK sends `[Amplitude] Page Viewed` on init and again when the URL changes inside the single-page app, which covers `<Link>` clicks and `navigate()`. Each event carries `[Amplitude] Page Path`, `[Amplitude] Page Title`, `[Amplitude] Page URL`, and `[Amplitude] Page Counter`.

`trackHistoryChanges: 'pathOnly'` sends a page view when the path changes, such as `/pricing` to `/docs`, and skips changes to only the query string or hash. Use the default, `'all'`, if your query strings mark different pages, such as `/blog?page=2`. With `'all'`, a product list that writes `?sort=` and `?color=` to the URL records a page view per filter click, which inflates page views per session and skews entry and exit pages.

`HashRouter` keeps the route in the hash (`/#/pricing`), so the path is `/` on every screen. With `'pathOnly'`, route changes never send a page view. If you use `HashRouter`, set `pageViews: true`, which tracks hash changes, and break page reports down by `[Amplitude] Page Location` (the full URL) rather than `[Amplitude] Page Path`. `BrowserRouter` avoids both problems.

Don't add a `useEffect` on `useLocation()` that calls `track('Page Viewed')`. It produces a second, differently named page view per navigation, and Marketing Analytics only counts the one it's configured to read.

## 4. Keep UTM parameters through React Router redirects

The SDK can only record campaign data that's in the URL. A React Router redirect such as `<Navigate to="/pricing" replace />` builds a new location from the path you give it and drops the query string. A visitor arrives at `/plans?utm_source=newsletter`, React Router sends them to `/pricing` with nothing after it, and every event on `/pricing` reads an empty campaign. Amplitude's docs call this out for single-page apps: UTM parameters can drop during SPA redirects.

Carry `search` and `hash` across in one small component:

src/routing/RedirectWithQuery.tsx

```typescript
import { Navigate, useLocation } from 'react-router-dom';

export function RedirectWithQuery({ to }: { to: string }) {
  const { search, hash } = useLocation();

  // <Navigate to={to} replace /> on its own drops ?utm_source=...
  return <Navigate to={{ pathname: to, search, hash }} replace />;
}
```

Use it for every redirect route:

src/App.tsx

```typescript
import { Routes, Route } from 'react-router-dom';
import { RedirectWithQuery } from './routing/RedirectWithQuery';
import { Home } from './pages/Home';
import { Pricing } from './pages/Pricing';

export function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/pricing" element={<Pricing />} />
      {/* Old campaign URL. Keeps ?utm_... on the way to /pricing. */}
      <Route path="/plans" element={<RedirectWithQuery to="/pricing" />} />
    </Routes>
  );
}
```

If your `App.tsx` already has an `IdentifyUser` component from the React product analytics page, keep it and add the `/plans` route next to your existing routes.

**Why.** Event property tracking re-reads campaign parameters on page load and on every SPA navigation, including the `replaceState` a redirect performs. Whatever is in the URL after the redirect is what gets attached to the events that follow.

The same rule applies to every other redirect in the app: a `navigate('/welcome', { replace: true })` after sign-in, an auth guard that bounces to `/login`, or a data router loader that returns `redirect()`. Pass the current `search` along, and check each one with a `?utm_source=test` URL before you trust your channel numbers.

## 5. Set up the Marketing Analytics views

In Amplitude, web analytics lives under **Marketing Analytics** in the navigation bar. It uses Browser SDK events by default, so this step is configuration, not code.

1. **Create the channel classifier.** Go to Data > Properties > Channels. Opening that page creates `[Amplitude] Default Channels`, which maps UTM parameters and referrer data into GA4-aligned channels such as Paid Search and Organic Social. If your org predates it, click **Build with the new logic** when prompted.
2. **Confirm the page view event.** Open Marketing Analytics, click **Customize**, and select Page View and Filter. Choose `[Amplitude] Page Viewed` as the event. Set a domain filter if the project also receives events from another site.
3. **Define a conversion funnel.** Under Conversion Funnel, choose the events that make up your funnel. If you followed the [React product analytics page](/build/react-product-analytics), `[Amplitude] Page Viewed` followed by `Project Created` works as a first funnel.
4. **Read the four views.** Traffic by Channel, Traffic by Campaign, Page Engagement, and Conversion each show a time series and a table. Ad Performance needs an ad network source such as Google Ads connected first.

For every setting, see [Out-of-the-box Marketing Analytics](https://amplitude.com/docs/analytics/ootb-marketing-analytics).

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy before it writes `track` calls, so a conversion event matches a name you already use instead of inventing `projectCreated` next to `Project Created`. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## Verify it worked

Run `npm run dev`, open `http://localhost:5173/plans?utm_source=build-test&utm_medium=email&utm_campaign=react-web-analytics` in a fresh private window, click through two routes, then check in this order:

1. **Address bar.** After the redirect you should be on `/pricing` with all three `utm_` parameters still in the URL. If they're gone, the fix is step 4, and nothing downstream will work until it is.
2. **Network tab.** Filter for `api2.amplitude.com`. You should see a `POST` returning `200`. The SDK compresses request bodies of 2 KB or more, so read the events in the Instrumentation Explorer Chrome extension instead: the `[Amplitude] Page Viewed` event for `/pricing` should carry `utm_source: build-test` in `event_properties`, and an identify event should set `utm_source` and `initial_utm_source` as user properties.
3. **Ingestion Debugger.** In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads. It shows counts, not individual events.
4. **User lookup.** Go to Users > User Profiles and search your device ID (the Instrumentation Explorer Chrome extension shows it as device\_id). Within ten seconds to a minute, the event stream shows `[Amplitude] Start Session`, then one `[Amplitude] Page Viewed` per route, and the profile shows `initial_utm_source: build-test`.
5. **Marketing Analytics.** Open Traffic by Campaign for today. `react-web-analytics` should appear as a campaign.

To watch payloads in the console, add `logLevel: amplitude.Types.LogLevel.Debug` to the `analytics` block in development.

## Troubleshooting

### Two page view events per navigation

A hand-written page view hook is running alongside autocapture. Delete it. If both events are `[Amplitude] Page Viewed`, check that `initAll` is only called from `src/lib/amplitude.ts`.

### A page view every time a filter or sort changes

The page writes state to the query string and `trackHistoryChanges` is `'all'`. Set `pageViews: { trackHistoryChanges: 'pathOnly' }` as in step 2.

### Only one page view per visit, no matter how many routes

The app uses `HashRouter` with `trackHistoryChanges: 'pathOnly'`. Every route shares the path `/`, so no path change ever happens. Set `pageViews: true`, or move to `BrowserRouter`.

### Campaign traffic shows up as Direct

The UTM parameters were gone by the time the SDK read them. Load a `?utm_source=test` URL and watch the address bar: a `<Navigate>` or `navigate()` call that doesn't pass `search` is the usual cause. The Channels docs also note that inconsistent `set()` and `unset()` calls on UTM user properties can classify traffic as Other.

### Every page in Page Engagement has the same title

Your app sets `document.title` once in `index.html` and never changes it, so every `[Amplitude] Page Title` is identical. Break the view down by `[Amplitude] Page Path` under Customize > Breakdown, or set a title per route.

### Page views stopped even though the code enables them

Remote configuration overrides local autocapture settings feature by feature, and Browser SDK 2.16.1 and later fetch it by default. Check Data > Settings > Autocapture and make sure Page Views and Sessions are on.

### No requests in the Network tab

The key is undefined, so the warning from step 2 is in the console. The variable must start with `VITE_`, and Vite only reads `.env.local` at startup, so restart the dev server. In a production build the value is inlined at build time; set it in your build environment and rebuild.

### Requests fail with ERR\_BLOCKED\_BY\_CLIENT

An ad blocker is dropping the call. Route events through a [proxy on your own domain](https://amplitude.com/docs/analytics/domain-proxy) and point `serverUrl` at it. Point `remoteConfig.serverUrl` at it too, so remote configuration requests aren't blocked.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 6 files.

6 files, 68 lines

```typescript
// -- .env.local --
VITE_AMPLITUDE_API_KEY=your-project-api-key

// -- src/lib/amplitude.ts --
import * as amplitude from '@amplitude/unified';

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

if (apiKey) {
  amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: { trackHistoryChanges: 'pathOnly' },
        sessions: true,
        pageUrlEnrichment: true,
        formInteractions: true,
        fileDownloads: true,
      },
  });
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

export { amplitude };

// -- src/main.tsx --
import './lib/amplitude';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);

// -- src/routing/RedirectWithQuery.tsx --
import { Navigate, useLocation } from 'react-router-dom';

export function RedirectWithQuery({ to }: { to: string }) {
  const { search, hash } = useLocation();
  return <Navigate to={{ pathname: to, search, hash }} replace />;
}

// -- src/App.tsx --
import { Routes, Route } from 'react-router-dom';
import { RedirectWithQuery } from './routing/RedirectWithQuery';
import { Home } from './pages/Home';
import { Pricing } from './pages/Pricing';

export function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/pricing" element={<Pricing />} />
      <Route path="/plans" element={<RedirectWithQuery to="/pricing" />} />
    </Routes>
  );
}

// -- src/pages/Home.tsx and src/pages/Pricing.tsx --
// Your existing pages. Nothing in them changes.
```

## Hand it to your agent

Prefer to have Cursor or Claude Code do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt

```text
Set up Amplitude web analytics in this Vite + React Router project by
following https://amplitude.com/build/react-web-analytics exactly.
Install @amplitude/unified (or keep @amplitude/analytics-browser if it's
already installed, in which case call init(apiKey, { autocapture }) with
no analytics wrapper instead of initAll). Call initAll once at module scope in
src/lib/amplitude.ts, reading VITE_AMPLITUDE_API_KEY from
import.meta.env, and import that module first in src/main.tsx. Inside
analytics: { autocapture } keep attribution, sessions, and
pageUrlEnrichment on, and set pageViews to
{ trackHistoryChanges: 'pathOnly' } unless the app uses HashRouter, in
which case use pageViews: true. Don't add a sessionReplay sample rate.
Do not initialize inside useEffect and do not add a manual page view
hook. Find every <Navigate>, navigate(..., { replace: true }), and loader
redirect() in the app and make each one carry location.search and
location.hash to the new URL. Don't hardcode the key and don't invent
event names. Report every file you changed and every redirect you
checked.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay) lets you watch what visitors with `utm_campaign: react-web-analytics` did after they landed on `/pricing`, once you set a sample rate.
- [Heatmaps](https://amplitude.com/heatmaps), built from those session replays, show where visitors click on `/pricing`, and a segment on `initial_utm_source` splits paid from organic.
- [Web Experiment](https://amplitude.com/web-experimentation) can test a new `/pricing` layout, launched from the Page Engagement table, with your conversion funnel as the metric.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can ask visitors whose `utm_medium` is `email` what brought them back.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksAdd product analytics to a React appIdentify signed-in users and track \`Project Created\`, the event your conversion funnel needs.](/build/react-product-analytics)[Web frameworksAdd web analytics to a Next.js appThe same traffic setup when your React app renders on the server and redirects in middleware.](/build/nextjs-web-analytics)[Web frameworksRun a Web Experiment in a Next.js appTest a page change without a deploy, measured against the same traffic data.](/build/nextjs-web-experiment)

Reference docs

[Out-of-the-box Marketing Analytics →](https://amplitude.com/docs/analytics/ootb-marketing-analytics)[Browser SDK 2: Track marketing attribution →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2#track-marketing-attribution)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Channels →](https://amplitude.com/docs/data/channels)[Debug Analytics →](https://amplitude.com/docs/analytics/debug-analytics)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

Amplitude is the leading [all-in-one analytics platform](https://amplitude.com/) for building better products. Product and engineering teams use Amplitude to connect [product analytics](https://amplitude.com/amplitude-analytics) and [web analytics](https://amplitude.com/web-analytics) with [session replay](https://amplitude.com/session-replay), [heatmaps](https://amplitude.com/heatmaps), [experimentation](https://amplitude.com/amplitude-experiment), [feature flags](https://amplitude.com/feature-management), and [guides and surveys](https://amplitude.com/guides-and-surveys) in one unified workflow, with [AI agents](https://amplitude.com/ai-agents) working across all of it. Learn how Amplitude is building [self-improving products](https://amplitude.com/self-improving-products) with [Wave](https://amplitude.com/wave), helping teams turn product data into PRs.

For agents

[View raw .md](/build/react-web-analytics.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
