---
title: "How to add product analytics to a React app"
description: "Install Amplitude in a Vite and React Router app, capture page views on client-side navigation, identify signed-in users, and confirm events land."
canonical: "https://amplitude.com/en-us/build/react-product-analytics"
---

# How to add product analytics to a React app

Install Amplitude in a Vite and React Router app, capture page views on client-side navigation, identify signed-in users, and confirm events land.

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

---

# How to add product analytics to a React app

Install the Amplitude Unified SDK in a Vite and React Router app, capture page views on every client-side navigation, identify signed-in users, and track one custom event. About 10 minutes, all copy-paste.

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

Chris Tam · Last updated 24 September 2026

React is the most widely used library for building web interfaces. [Product analytics](https://amplitude.com/amplitude-analytics) shows you what people do inside your React app: which pages they visit, what they use, and where they drop off.

In this tutorial, you'll add Amplitude to a Vite and React Router app, capture page views, identify signed-in users, and track your first custom event.

## What you'll build

A React app that sends `[Amplitude] Page Viewed` on the first load and every route change, attaches the signed-in user's ID and `plan` to their events, clears identity on sign-out, and tracks a `Project Created` event with a `template` property. By the end you'll see live events in Amplitude's event stream.

## Prerequisites

- A Vite project with React 18 or later and React Router. `npm create vite@latest` with the React and TypeScript template is fine.
- An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.

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. It bundles Analytics, session replay, Feature Experiment, and guides and surveys behind one init call.

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. Initialize before React renders

Create one module that initializes the SDK at import time, then import it first in your entry file. Module code runs once, before `createRoot`, and Strict Mode can't replay it.

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: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      sampleRate: 1,
    },
  });
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

export { amplitude };
```

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.** An ES module body executes once per page load no matter how many components import it, so there's nothing to guard. A `useEffect` would run after the first paint and, in development, twice. Clicks (`elementInteractions`) default to off in autocapture, which is why the config names it explicitly.

`sessionReplay.sampleRate: 1` records every session, the same rate the Quickstart's snippet and AI prompt use. If you don't want replays yet, set it to `0`. For the full option list, see the [Browser Unified SDK reference](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk).

## 3. Let autocapture handle route changes

There's no code in this step, and that's the point. With `pageViews: true`, the SDK sends `[Amplitude] Page Viewed` on init and again on every URL change inside the single-page app, which covers React Router's `navigate()` and `<Link>` clicks. Each event carries the page path, title, and URL.

Don't add a `useEffect` on `useLocation()` that calls `track('Page Viewed')`. It produces a second, differently named page view per navigation.

If you use hash links and only want page views when the path changes, set `pageViews: { trackHistoryChanges: 'pathOnly' }` inside `analytics.autocapture`.

## 4. Identify the signed-in user

Until you set a user ID, events belong to an anonymous device ID. Set it as soon as your auth state resolves, and reset only when someone signs out, so the next person on the same browser isn't merged into the last one.

src/analytics/IdentifyUser.tsx

```typescript
import { useEffect } from 'react';
import { amplitude } from '../lib/amplitude';

type User = { id: string; plan: string } | null;

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    // Signed out or not signed in yet: keep the same device ID.
    // Reset only from your sign-out handler (next step).
    if (!user) return;

    // Amplitude drops IDs shorter than 5 characters. Use the same
    // string your backend sends as user_id.
    amplitude.setUserId(`user-${user.id}`);

    const identify = new amplitude.Identify();
    identify.set('plan', user.plan);
    amplitude.identify(identify);
  }, [user?.id, user?.plan]);

  return null;
}
```

Reset from your sign-out handler, not from `IdentifyUser`. A `null` user also describes every anonymous visitor on every page load, and `reset()` issues a new device ID each time. That splits one visitor into many and breaks anything bucketed by device ID, such as feature flags and Web Experiment.

src/auth/signOut.ts

```typescript
import { amplitude } from '../lib/amplitude';

export async function signOut() {
  // ... your sign-out logic
  // Clears the user ID and issues a new device ID for the next person.
  amplitude.reset();
}
```

Mount it once, wherever your auth state lives:

src/App.tsx

```typescript
import { Routes, Route } from 'react-router-dom';
import { IdentifyUser } from './analytics/IdentifyUser';
import { useAuth } from './auth/useAuth';
import { Home } from './pages/Home';
import { NewProject } from './pages/NewProject';

export function App() {
  const { user } = useAuth(); // your auth hook

  return (
    <>
      <IdentifyUser user={user} />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/projects/new" element={<NewProject />} />
      </Routes>
    </>
  );
}
```

**Why.** This effect is safe to run twice in Strict Mode, because setting the same user ID and the same property again changes nothing. The dependency array uses the ID and plan rather than the `user` object, so a new object with the same values doesn't re-identify on every render. The `plan` value updates the profile right away, but only shows in charts from the user's next event.

## 5. Track one custom event

Autocapture covers page views, sessions, forms, and clicks. Track the events that mean something to your product yourself, with properties you'll want to break down by.

src/pages/NewProject.tsx

```typescript
import { useState } from 'react';
import { amplitude } from '../lib/amplitude';

export function NewProject() {
  const [template, setTemplate] = useState('blank');

  async function handleCreate() {
    // ... your create logic

    amplitude.track('Project Created', { template });
  }

  return (
    <div>
      <select value={template} onChange={(e) => setTemplate(e.target.value)}>
        <option value="blank">Blank</option>
        <option value="kanban">Kanban</option>
      </select>
      <button onClick={handleCreate}>Create project</button>
    </div>
  );
}
```

Call `track` from the event handler, not from an effect that watches state. Handlers run once per click; effects rerun whenever their dependencies change.

**MCP.** Working in Cursor, Claude Code, or Codex CLI? Amplitude's MCP server lets your agent read your project's existing event taxonomy before it writes `track` calls, so new events match names 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`, click through two or three routes, create a project, then check in this order:

1. **Network tab.** Filter for `api2.amplitude.com`. You should see a `POST` to `/2/httpapi` returning `200` within about a second of each navigation, since the Browser SDK flushes every second or every 30 events.
2. **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.
3. **User lookup.** Go to Users > User Profiles and search your `user-` prefixed test ID. The event stream shows `[Amplitude] Page Viewed` for each route and `Project Created` with its `template` property within ten seconds to a minute, and the profile shows `plan`.

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

## Troubleshooting

### No requests in the Network tab

The key is undefined, so the warning from step 2 is in the console. The env var must start with `VITE_`, and Vite only reads `.env.local` at startup, so restart the dev server.

### 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`.

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

An ad blocker is dropping the call. Amplitude recommends routing events through a proxy on your own domain and pointing `serverUrl` at it. Point `remoteConfig.serverUrl` at it too, so remote configuration requests aren't blocked.

### Signed-in events still show as anonymous

`IdentifyUser` isn't mounted, or it mounts before `useAuth` has a user and never reruns. Confirm `user.id` is in the dependency array.

### Events send but never appear in the project

EU projects need `serverZone: 'EU'` at the top level of the `initAll` options and an API key from the EU project.

## Full code reference

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

7 files, 113 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: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      sampleRate: 1,
    },
  });
} 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/analytics/IdentifyUser.tsx --
import { useEffect } from 'react';
import { amplitude } from '../lib/amplitude';

type User = { id: string; plan: string } | null;

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    if (!user) return;
    amplitude.setUserId(`user-${user.id}`);
    const identify = new amplitude.Identify();
    identify.set('plan', user.plan);
    amplitude.identify(identify);
  }, [user?.id, user?.plan]);

  return null;
}

// -- src/auth/signOut.ts --
import { amplitude } from '../lib/amplitude';

export async function signOut() {
  // ... your sign-out logic
  // Clears the user ID and issues a new device ID for the next person.
  amplitude.reset();
}

// -- src/App.tsx --
import { Routes, Route } from 'react-router-dom';
import { IdentifyUser } from './analytics/IdentifyUser';
import { useAuth } from './auth/useAuth';
import { Home } from './pages/Home';
import { NewProject } from './pages/NewProject';

export function App() {
  const { user } = useAuth();
  return (
    <>
      <IdentifyUser user={user} />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/projects/new" element={<NewProject />} />
      </Routes>
    </>
  );
}

// -- src/pages/NewProject.tsx --
import { useState } from 'react';
import { amplitude } from '../lib/amplitude';

export function NewProject() {
  const [template, setTemplate] = useState('blank');

  async function handleCreate() {
    amplitude.track('Project Created', { template });
  }

  return (
    <div>
      <select value={template} onChange={(e) => setTemplate(e.target.value)}>
        <option value="blank">Blank</option>
        <option value="kanban">Kanban</option>
      </select>
      <button onClick={handleCreate}>Create project</button>
    </div>
  );
}
```

## Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI 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
Add Amplitude analytics to this Vite + React Router app by following
https://amplitude.com/build/react-product-analytics exactly. Install
@amplitude/unified and call initAll once at module scope in
src/lib/amplitude.ts, reading VITE_AMPLITUDE_API_KEY from import.meta.env,
with autocapture pageViews and elementInteractions on. Import that module
first in src/main.tsx. Do not initialize inside useEffect. Do not add a
manual page view hook; autocapture tracks route changes. Set the user ID
with a "user-" prefix where auth resolves and call reset() on sign-out.
Tell me that Session Replay is on at sampleRate 1 and how to turn it off.
Don't hardcode the key and don't invent event names. Report every file you
changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay), already on from step 2, lets you watch the session where `Project Created` fired with `template: kanban`.
- [Heatmaps](https://amplitude.com/heatmaps) show where people click on `/projects/new` before they create one, built from the session replays step 2 turned on.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in the same package, can test a new template picker with `Project Created` as the metric.
- [Guides and surveys](https://amplitude.com/guides-and-surveys), also bundled, can prompt users who viewed `/projects/new` but never fired `Project Created`.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add product analytics to a Next.js appThe same setup when your React app renders on the server.](/build/nextjs-product-analytics)[Web frameworksAdd analytics to your Node.js backendTrack billing events server-side with the same user ID.](/build/nodejs-product-analytics)[Use caseProxy events through your domainKeep ad blockers from dropping your events.]()

Reference docs

[Debug Analytics →](https://amplitude.com/docs/analytics/debug-analytics)[Amplitude Quickstart →](https://amplitude.com/docs/get-started/amplitude-quickstart)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Browser SDK 2 →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2)[Autocapture →](https://amplitude.com/docs/data/autocapture)[Session replay →](https://amplitude.com/docs/session-replay)[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-product-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)
