---
title: "How to add session replay to a React app"
description: "Record sessions in a Vite and React Router app with Amplitude, sample per environment, mask components and routes, and confirm a replay lands."
canonical: "https://amplitude.com/en-us/build/react-session-replay"
---

# How to add session replay to a React app

Record sessions in a Vite and React Router app with Amplitude, sample per environment, mask components and routes, and confirm a replay lands.

Source: https://amplitude.com/en-us/build/react-session-replay

---

# How to add session replay to a React app

Turn on Amplitude session replay in a Vite and React Router app, record every session in development and a small share in production, mask sensitive components and whole routes, and open your own test replay. 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 bug report rarely comes with the state that caused it. [Session replay](https://amplitude.com/session-replay) records what each user saw and did, so "the button didn't work" becomes a session you can watch.

In this tutorial, you'll turn on session replay in a Vite and React Router app, pick a sample rate, mask sensitive components and routes, and confirm a replay lands. It builds on the [React product analytics page](/build/react-product-analytics).

## What you'll build

A React app that records every session in development and 1% of sessions in production, masks text inputs by default, masks any element marked `data-amp-mask` and every screen under `/billing`, replaces elements marked `data-amp-block` with a placeholder, and tags the session's events with `[Amplitude] Session Replay ID`. By the end you'll play back your own test session from your event stream in Amplitude.

## Prerequisites

- A Vite project with React 18 or later, TypeScript, and React Router (`react-router-dom`). `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.
- To change sampling or masking in Amplitude's settings, an Admin or Manager role in the project. The code on this page doesn't need it.

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 `initAll` call, and session replay records once `sessionReplay.sampleRate` is above 0. If you followed the [React product analytics page](/build/react-product-analytics), it's already installed; skip to step 2.

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

Already on `@amplitude/analytics-browser` to keep bundle size down? The [session replay plugin](https://amplitude.com/docs/sdks/session-replay/session-replay-plugin) takes the same options as the `sessionReplay` block below. Add it to your existing init rather than installing a second SDK.

## 2. Set a sample rate per environment

Initialize once, at module scope, and choose the sample rate from Vite's build mode. This file replaces `src/lib/amplitude.ts` from the React product analytics page; the `analytics` block is unchanged.

src/lib/amplitude.ts

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

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;
const isProduction = import.meta.env.PROD;

if (apiKey) {
  amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        // Start Session and End Session carry [Amplitude] Session Replay ID.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      // Fraction of sessions to record: every session in `vite dev`, 1% in a production build.
      sampleRate: isProduction ? 0.01 : 1,
      // Ignores the focus check so replays record with DevTools open. Setup only.
      debugMode: !isProduction,
      // Selectors for the data attributes in steps 3 and 4.
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
  });
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; Amplitude 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.** Session replay takes a full snapshot of the page when it initializes, and nothing that happens before that point appears in the replay. Module code runs once, before `createRoot`, so recording starts as early as the app allows. It also matters for matching: initializing the SDK more than once can leave the replay and the session's events with different device or session IDs, and a replay that doesn't match its events is one you can't open from the event stream. A module body runs once per page load, and Strict Mode doesn't replay it the way it replays effects.

The plugin's own default sample rate is `0`, so nothing records until you set one. Amplitude recommends starting production low, around `0.01`, and raising it over a few days; capture stops for the month once your org's quota runs out. To stop recording, set the rate to `0`. Session Replay Settings in Amplitude can also set a project's rate remotely, so check Settings > Organizational Settings > Session Replay Settings when the rate you see doesn't match your code. For every option, see the [session replay plugin configuration](https://amplitude.com/docs/sdks/session-replay/session-replay-plugin#configuration); the Unified SDK accepts all of them inside `sessionReplay` except `serverZone` and `instanceName`, which go at the top level of the `initAll` options.

Unified also initializes guides and surveys by default. If you won't use them, the [Unified SDK docs](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk) describe an `engagement: { skip: true }` option that stops that SDK from loading.

## 3. Mask sensitive components in JSX

Session replay masks every text input by default: typed characters arrive as asterisks. The project's default masking level, Medium, masks all form fields and text inputs and captures other text as-is. Everything else is your call, element by element.

The SDK recognizes three classes, `amp-mask`, `amp-block`, and `amp-unmask`. In a component that already uses a CSS Modules class, though, `className={styles.email}` replaces the string, so `amp-mask` has to be concatenated in by hand and quietly disappears the first time someone refactors the line. The data attributes registered in step 2 sit next to `className` instead of inside it:

src/pages/Team.tsx

```typescript
import styles from './Team.module.css';

type Member = { id: string; name: string; email: string; avatarUrl: string };

export function Team({ members }: { members: Member[] }) {
  return (
    <main>
      <h1>Team</h1>

      {/* Inputs are masked by default. This one is safe to watch. */}
      <input data-amp-unmask name="filter" placeholder="Filter by name" />

      <ul>
        {members.map((member) => (
          <li key={member.id} className={styles.row}>
            {/* Replaced by a same-size placeholder */}
            <img data-amp-block src={member.avatarUrl} alt="" />
            <span>{member.name}</span>
            {/* Captured as asterisks */}
            <span data-amp-mask className={styles.email}>
              {member.email}
            </span>
          </li>
        ))}
      </ul>
    </main>
  );
}
```

React renders `data-amp-mask` as `data-amp-mask="true"`, which the `[data-amp-mask]` selector matches. Prefer a class? `className="amp-mask"` works on elements with no other class.

**Why.** Masking covers text and form inputs, not HTML attributes. `alt`, `title`, `placeholder`, `aria-label`, `value`, and `data-*` attributes stay visible in replays even inside a masked element. A tooltip written as `title={member.email}` leaks the address no matter what you mask, so keep personal data out of attributes. Don't target CSS Modules class names as masking selectors either, in code or in Session Replay Settings; they're generated at build time and can change between builds, at which point the rule stops matching without any error.

When the masking rules in Session Replay Settings and the SDK disagree about an element, the settings page wins. You can change the project's level (Light, Medium, Conservative) or add selector overrides there, per project; see [Manage privacy settings for session replay](https://amplitude.com/docs/session-replay/manage-privacy-settings-for-session-replay).

## 4. Mask whole routes with a layout route

Some screens shouldn't show any text in a replay, such as billing, invoices, or a patient record. Masking each element there is how one gets missed. React Router's layout routes give you one wrapper for a whole section:

src/routing/MaskedArea.tsx

```typescript
import { Outlet } from 'react-router-dom';

export function MaskedArea() {
  // Everything rendered under this route is masked, including screens added later.
  return (
    <div data-amp-mask>
      <Outlet />
    </div>
  );
}
```

src/App.tsx

```typescript
import { Routes, Route } from 'react-router-dom';
import { MaskedArea } from './routing/MaskedArea';
import { Home } from './pages/Home';
import { Team } from './pages/Team';
import { Billing } from './pages/Billing';
import { Invoices } from './pages/Invoices';

export function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/team" element={<Team members={[]} />} />
      <Route element={<MaskedArea />}>
        <Route path="/billing" element={<Billing />} />
        <Route path="/billing/invoices" element={<Invoices />} />
      </Route>
    </Routes>
  );
}
```

Replace `members={[]}` with your data. If your `App.tsx` already mounts `IdentifyUser` from the React product analytics page, keep it above `<Routes>`.

**Why.** Masking an element through a selector masks all of its children, and a child inside it can't be unmasked again. That's what you want here: a developer who adds `/billing/payment-methods` next year gets masking without knowing this file exists. It also means `data-amp-unmask` does nothing under `/billing`, so put content you want visible outside the layout route.

The layout route masks text, not structure. Clicks, scrolling, and layout still record, so you can follow what the user did on `/billing` without reading it. To hide a region entirely, use `data-amp-block` on the wrapper instead.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server includes `get_session_replays`, and its `debug-replay` skill turns a bug report into numbered reproduction steps from the replay's interaction timeline, so you don't have to scrub through sessions by hand. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 5. Allow Amplitude in your CSP

A Vite app sends no `Content-Security-Policy` by default. Skip this step unless you added one, in a `<meta>` tag in `index.html` or in your host's response headers. If you did, add Amplitude's hosts to the `connect-src` directive you already have:

Add to your existing Content-Security-Policy (US data center)

```text
connect-src https://api2.amplitude.com https://api-sr.amplitude.com https://sr-client-cfg.amplitude.com;
```

`api-sr.amplitude.com` receives replay data and `sr-client-cfg.amplitude.com` serves the session replay remote configuration. `api2.amplitude.com` is where the analytics SDK sends events. EU projects use `api-sr.eu.amplitude.com` and `sr-client-cfg.eu.amplitude.com` and set `serverZone: 'EU'` at the top level of the `initAll` options. Add `worker-src blob:` only if you turn on `useWebWorker`. Unified also loads guides and surveys by default, which talks to `https://gs.amplitude.com` and loads files from `https://cdn.amplitude.com` and `https://engagement-static.amplitude.com`. Allow those too, or turn guides and surveys off with `engagement: { skip: true }`.

**Why.** If remote configuration fails to load, session replay captures nothing, on purpose, so it never records without the privacy settings you defined in Amplitude. A blocked config request looks like "replay does nothing" rather than like an error, until you open the console and find a `Content Security Policy` message.

## Verify it worked

Run `npm run dev`, open `http://localhost:5173` in a fresh private window, visit `/team`, type into the filter, open `/billing`, then check in this order:

1. **Console and Network tab.** No `Content Security Policy` errors in the console. In the Network tab, filter for `amplitude`: you should see event uploads to `api2.amplitude.com` returning `200`, a request to `sr-client-cfg.amplitude.com`, and replay uploads to `api-sr.amplitude.com`.
2. **Ingestion Debugger.** In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your event uploads. It shows counts, not individual events.
3. **User lookup.** Go to Users > User Profiles and search your device ID (the Instrumentation Explorer Chrome extension shows it as device\_id) or your user ID if you're signed in. Within ten seconds to a minute, the event stream shows `[Amplitude] Start Session` carrying `[Amplitude] Session Replay ID`, then a **Play Session** button next to that session. Replays are generally ready about five minutes after the session begins.
4. **The replay itself.** Play it. On `/team`, the filter text is visible, emails are asterisks, and avatars are grey placeholders. On `/billing`, every line of text is masked while clicks and scrolling still show.

If the replay won't load, open the Ingestion Monitor from the top right of the Session Replay section of your project's Users & Sessions tab. It shows replay request statuses, including `429` throttling and `402` quota errors.

To force a fresh session while testing, delete the cookies that start with `AMP_` in DevTools and reload.

Replays recorded against `localhost` can play back unstyled, because Amplitude can't fetch files from your machine. Check styling against a deployed preview.

## Troubleshooting

### No session replay requests at all

The effective sample rate is `0`. Check `sessionReplay.sampleRate`, then the rate in Session Replay Settings. A production build (`vite build`, then `vite preview`) records only about one session in a hundred at `0.01`, so test in `vite dev` or raise the rate temporarily.

### Replays record in production but not while you debug

Session replay only captures the tab that has focus, and an open DevTools panel can take it. Keep `debugMode: !isProduction` from step 2, which ignores the focus check in development.

### Events arrive but the session has no Play Session button

No event in that session carries `[Amplitude] Session Replay ID`. The usual cause is a second `initAll` or `init` call, for example inside a component or a `useEffect`, so the replay and the events disagree on device or session ID. Search the codebase and keep the one in `src/lib/amplitude.ts`.

### Masked text shows up in a replay

Check three things in order. The text sits in an attribute such as `title` or `aria-label`, which masking never covers. A masking rule targets a CSS Modules class name that changed in the last build; switch it to a data attribute. Or the element was unmasked in Session Replay Settings, which overrides the SDK.

### Old replays lose their styling after a deploy

Vite writes CSS to hashed file names under `dist/assets/`, and a deploy that replaces the folder removes the old ones. Keep previous build assets available on your host, allow `app.amplitude.com` in your asset server's CORS policy, and add `crossorigin="anonymous"` to any external `<link rel="stylesheet">`.

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

An ad blocker is dropping them. Session replay isn't compatible with ad blocking software, so test in a clean profile. For production traffic, route requests through your own domain and point `trackServerUrl` and `configServerUrl` at it.

### No requests to Amplitude at all

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.

## Full code reference

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

7 files, 111 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;
const isProduction = import.meta.env.PROD;

if (apiKey) {
  amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      sampleRate: isProduction ? 0.01 : 1,
      debugMode: !isProduction,
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
  });
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; Amplitude 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/MaskedArea.tsx --
import { Outlet } from 'react-router-dom';

export function MaskedArea() {
  return (
    <div data-amp-mask>
      <Outlet />
    </div>
  );
}

// -- src/App.tsx --
import { Routes, Route } from 'react-router-dom';
import { MaskedArea } from './routing/MaskedArea';
import { Home } from './pages/Home';
import { Team } from './pages/Team';
import { Billing } from './pages/Billing';
import { Invoices } from './pages/Invoices';

export function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/team" element={<Team members={[]} />} />
      <Route element={<MaskedArea />}>
        <Route path="/billing" element={<Billing />} />
        <Route path="/billing/invoices" element={<Invoices />} />
      </Route>
    </Routes>
  );
}

// -- src/pages/Team.tsx --
import styles from './Team.module.css';

type Member = { id: string; name: string; email: string; avatarUrl: string };

export function Team({ members }: { members: Member[] }) {
  return (
    <main>
      <h1>Team</h1>
      <input data-amp-unmask name="filter" placeholder="Filter by name" />
      <ul>
        {members.map((member) => (
          <li key={member.id} className={styles.row}>
            <img data-amp-block src={member.avatarUrl} alt="" />
            <span>{member.name}</span>
            <span data-amp-mask className={styles.email}>
              {member.email}
            </span>
          </li>
        ))}
      </ul>
    </main>
  );
}

// -- src/pages/Home.tsx, Billing.tsx, Invoices.tsx, Team.module.css --
// Your existing pages and styles. 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
Add Amplitude session replay to this Vite + React Router app by following
https://amplitude.com/build/react-session-replay exactly. If the app
already uses @amplitude/analytics-browser, add
@amplitude/plugin-session-replay-browser to the existing init instead of
installing a second SDK, and pass the same options to
sessionReplayPlugin({...}) before amplitude.add it. Otherwise use
@amplitude/unified. 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. Never initialize inside a component or useEffect. Set
sessionReplay.sampleRate to 1 in development and 0.01 when
import.meta.env.PROD is true, with debugMode only in development, and
register [data-amp-mask], [data-amp-block], and [data-amp-unmask] in
sessionReplay.privacyConfig. Add data-amp-mask to elements that render
emails, names on billing documents, or account data, and data-amp-block
to images of people or documents. Wrap billing and any other sensitive
route group in a layout route that renders <div data-amp-mask><Outlet />.
Never use CSS Modules class names as masking selectors, and flag any
title, aria-label, alt, or placeholder attribute that contains personal
data. Don't hardcode the key. Report every file you changed and any CSP
you found.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay) sits on the same events as your charts: open a funnel step that ends at `/billing` and watch the sessions where users dropped, text masked.
- [Heatmaps](https://amplitude.com/heatmaps) are built from session replays, so once heatmap capture is on for the project in Session Replay Settings, clicks and scrolls on `/team` aggregate without new code.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in `@amplitude/unified`, lets you filter replays by variant and see how people use a new version of `/team`.
- [Guides and surveys](https://amplitude.com/guides-and-surveys), also bundled, can ask the users whose replays show a stall on `/billing` what they were looking for.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksAdd product analytics to a React appIdentify signed-in users, so you can find a replay by the user ID from a support ticket.](/build/react-product-analytics)[Web frameworksAdd web analytics to a React appAttribute sessions to channels and campaigns, then watch replays from one campaign.](/build/react-web-analytics)[Web frameworksAdd heatmaps to a Next.js appTurn the replay capture you just added into click and scroll maps.](/build/nextjs-heatmaps)

Reference docs

[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Session Replay Browser SDK Plugin →](https://amplitude.com/docs/sdks/session-replay/session-replay-plugin)[Manage privacy settings for session replay →](https://amplitude.com/docs/session-replay/manage-privacy-settings-for-session-replay)[Configure session replay sampling →](https://amplitude.com/docs/session-replay/configure-sampling)[Verify session replay capture →](https://amplitude.com/docs/session-replay/verify-capture)[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-session-replay.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)
