Chris Tam · Last updated 24 September 2026
React is the most widely used library for building web interfaces. Product 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@latestwith 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 to get your project API key.
1. Install the SDK
The 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.
npm install @amplitude/unifiedVite 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.
VITE_AMPLITUDE_API_KEY=your-project-api-key2. 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.
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 };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.
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.
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.
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:
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.
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.
Verify it worked
Run npm run dev, click through two or three routes, create a project, then check in this order:
- Network tab. Filter for
api2.amplitude.com. You should see aPOSTto/2/httpapireturning200within about a second of each navigation, since the Browser SDK flushes every second or every 30 events. - 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.
- User lookup. Go to Users > User Profiles and search your
user-prefixed test ID. The event stream shows[Amplitude] Page Viewedfor each route andProject Createdwith itstemplateproperty within ten seconds to a minute, and the profile showsplan.
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.
// -- .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.
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, already on from step 2, lets you watch the session where
Project Createdfired withtemplate: kanban. - Heatmaps show where people click on
/projects/newbefore they create one, built from the session replays step 2 turned on. - Feature Experiment, bundled in the same package, can test a new template picker with
Project Createdas the metric. - Guides and surveys, also bundled, can prompt users who viewed
/projects/newbut never firedProject Created.
One integration; everything else reads from the same events.
Next steps
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.