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

# How to add session replay to a React Router app (formerly Remix)

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

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

---

# How to add session replay to a React Router app (formerly Remix)

Turn on Amplitude session replay in a 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 Router 7+
- Framework mode
- Unified SDK 1.x
- 15 min

Chris Tam · Last updated 14 October 2026

Bug reports rarely include the state that caused them. [Session replay](https://amplitude.com/session-replay) records what each user of your React Router app saw and did, so "the button didn't work" becomes a session you can watch.

In this tutorial, you'll turn on session replay, set a sample rate per environment, mask sensitive components and routes, and confirm a replay lands. It builds on [How to add product analytics to a React Router app](/build/react-router-product-analytics).

## What you'll build

A React Router app that records every session in development and 1% of sessions in production, masks text inputs by default, masks anything wrapped in an `AmpMask` component and every page under `/billing`, replaces blocked elements 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

- The setup from [How to add product analytics to a React Router app](/build/react-router-product-analytics): `@amplitude/unified` installed, `VITE_AMPLITUDE_API_KEY` in `.env`, `initAll` in `app/entry.client.tsx`, and the root identity effect, so Verify can find your session by user ID.
- 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. The code on this page doesn't need it.

No Amplitude account yet? Session replay for web is available on all plans, so a free account covers this tutorial. [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) installs `@amplitude/unified` for browser apps that use 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 Router product analytics page](/build/react-router-product-analytics), it's already installed and `.env` holds `VITE_AMPLITUDE_API_KEY`; skip to step 2. Otherwise install it, then add the key to `.env` as that page's step 1 shows.

Terminal

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

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, in the client entry, and choose the sample rate from Vite's `import.meta.env.DEV` flag. This is the complete `app/entry.client.tsx`. The first comment names the lines other React Router pages add here; if you followed any of them, keep those lines and change only the `sessionReplay` block.

app/entry.client.tsx

```typescript
// Keep lines other React Router pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/react-router-web-analytics); the amplitude-cookies import and
// handoff lines, the deviceId spread, and the experiment block
// (/build/react-router-feature-flags); the setAmplitudeReady import, the
// setAmplitudeReady(...) wrapper, and setAmplitudeReady(null)
// (/build/react-router-feature-flags, /build/react-router-guides-surveys); the
// amplitude.add(webExperiment.plugin()) lines (/build/react-router-web-experiment).
// This page changes only sessionReplay.
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import * as amplitude from "@amplitude/unified";

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

if (apiKey) {
  void amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        // Tracks [Amplitude] Start Session and End Session events.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      // Fraction of sessions to record: every session in `npm run dev`, 1% in a production build.
      sampleRate: import.meta.env.DEV ? 1 : 0.01,
      // Ignores the focus check so replays record with DevTools open. Setup only.
      debugMode: import.meta.env.DEV,
      // 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; analytics is off.");
}

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </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. The client entry runs once, in the browser, before React hydrates the server-rendered HTML, so the masking attributes are already in the DOM, straight from the server HTML, when the first snapshot is taken. 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. An `initAll` in a route component's `useEffect` runs again on every visit to that route, and under `StrictMode` in development it runs twice on mount.

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); 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 elements in React components

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 out of the box, `amp-mask`, `amp-block`, and `amp-unmask`, and they work as `className` on any element. The data attributes registered in step 2 keep privacy separate from styling, and they're what the wrapper component below sets. Put one small component in charge of masking, so every route says it the same way:

app/components/AmpMask.tsx

```typescript
import type { HTMLAttributes, ReactNode } from "react";

type Props = HTMLAttributes<HTMLElement> & {
  // Replace the content with a same-size placeholder instead of asterisks.
  block?: boolean;
  // Use "span" inside inline content.
  as?: "div" | "span" | "section";
  children: ReactNode;
};

export function AmpMask({ block = false, as: Tag = "div", children, ...rest }: Props) {
  return (
    <Tag
      {...rest}
      data-amp-mask={block ? undefined : ""}
      data-amp-block={block ? "" : undefined}
    >
      {children}
    </Tag>
  );
}
```

Use it in any route or component, next to plain data attributes where a wrapper would be overkill:

app/routes/team.tsx

```typescript
import { AmpMask } from "../components/AmpMask";

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

// Replace with data from your loader.
const members: Member[] = [
  { id: "1", name: "Test User", email: "test@example.com", avatarUrl: "https://placehold.co/32" },
];

export default function Team() {
  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} style={{ display: "flex", gap: "0.5rem" }}>
            {/* Replaced by a same-size placeholder */}
            <AmpMask block as="span">
              <img src={member.avatarUrl} alt="" />
            </AmpMask>
            <span>{member.name}</span>
            {/* Captured as asterisks */}
            <AmpMask as="span">{member.email}</AmpMask>
          </li>
        ))}
      </ul>
    </main>
  );
}
```

React renders `data-amp-unmask=""` and the component's `""` values as empty attributes, which the attribute selectors match, and leaves an attribute off when its value is `undefined`. All of it renders on the server, so the attributes are in the HTML before the SDK starts.

**Why.** React components don't forward attributes to an element on their own. Writing `data-amp-mask` on a component tag, such as `<MemberEmail data-amp-mask="" />`, passes a prop the component ignores unless it spreads its props onto an element, and nothing in the replay tells you. The wrapper puts the attribute on an element you can see in the markup, and its `...rest` spread lets you pass `id`, `role`, or `className` through as well. Masking also follows the DOM, not the React tree: a dialog, popover, or toast that `createPortal` or a component library renders into `document.body` leaves its masked parent, so wrap the content inside the portal itself. And 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, so a tooltip written as `title={member.email}` leaks the address no matter what you mask.

Don't use generated class names, such as CSS Modules hashes like `_row_1x2y3`, as masking selectors, in code or in Session Replay Settings. They're produced at build time and change when the stylesheet changes, 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 pages 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. In React Router, a layout route wraps every child route through its `<Outlet />`, so one file covers a whole section:

app/routes/billing.tsx

```typescript
import { Outlet } from "react-router";
import { AmpMask } from "../components/AmpMask";

// Everything rendered under /billing is masked, including routes added later.
export default function BillingLayout() {
  return (
    <AmpMask>
      <Outlet />
    </AmpMask>
  );
}
```

Add two pages for the section, so there's text to check in the replay. If you already have billing routes, nest those instead:

app/routes/billing-home.tsx

```typescript
export default function BillingHome() {
  return (
    <>
      <h1>Billing</h1>
      <p>Card ending 4242, billed to Test User</p>
    </>
  );
}
```

app/routes/invoices.tsx

```typescript
export default function Invoices() {
  return <h1>Invoices</h1>;
}
```

Register them as children of the layout in `app/routes.ts`, next to your existing entries:

app/routes.ts

```typescript
// Keep lines other React Router pages added here: their routes
// (/build/react-router-product-analytics, /build/react-router-web-analytics,
// /build/react-router-heatmaps). Merge new entries into the array.
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  // ... your existing routes
  route("team", "routes/team.tsx"),
  route("billing", "routes/billing.tsx", [
    index("routes/billing-home.tsx"),
    route("invoices", "routes/invoices.tsx"),
  ]),
] satisfies RouteConfig;
```

Using `flatRoutes()` instead? `app/routes/billing.tsx` becomes the layout for `app/routes/billing._index.tsx` and `app/routes/billing.invoices.tsx` on its own. Your `app/root.tsx` with the identity effect from the React Router product analytics page stays as it is, and this layout renders inside it.

To mask several sections that don't share a URL prefix, such as `/billing` and `/account`, nest them under a pathless layout: `layout("routes/masked.tsx", [route("billing", ...), route("account", ...)])` with `layout` imported from `@react-router/dev/routes`. A pathless layout doesn't change the URL. If you added the account guard from the [web analytics page](/build/react-router-web-analytics), move its whole `route("account", ...)` entry, children included, inside the pathless layout.

**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 a `payment-methods` child route 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 section. React Router keeps the layout mounted while someone moves between `/billing` and `/billing/invoices`, so the wrapper never disappears mid-session.

The layout 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 the region entirely, use `<AmpMask block>` in the layout instead.

The layout doesn't cover the page `<title>`, which a route's `meta` export sets; keep personal data out of titles under `/billing`.

**MCP.** Working in Cursor or Claude Code? [Amplitude's MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) includes `get_session_replays`, and the debug-replay skill in Amplitude's MCP marketplace plugin 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.

## 5. Allow Amplitude in your CSP

React Router sends no Content-Security-Policy unless you add one, from a route's `headers` export, from `app/entry.server.tsx`, or at your host. Skip this step unless you did. If you did, add Amplitude's hosts to the `connect-src` directive you already have:

Content-Security-Policy (add to your existing connect-src; keep your other directives)

```text
connect-src 'self' 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 needs `https://*.amplitude.com` in `script-src`, `connect-src`, `img-src`, `media-src`, and `style-src`. Allow those, 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`. None of them start until the client entry runs in the browser; the server render sends nothing.
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](https://amplitude.com/docs/analytics/debug-analytics#instrumentation-explorer) shows it as `device_id`) or your `user-` prefixed user ID if you're signed in. Within ten seconds to a minute, the session's events, such as `[Amplitude] Start Session`, carry `[Amplitude] Session Replay ID`, and the event stream shows a Play Session button next to that session. Replays are generally ready about five minutes after the session begins.
4. **Session Replay debug panel.** Install the [Instrumentation Explorer](https://chrome.google.com/webstore/detail/amplitude-event-explorer/acehfjhnmhbmgkedjmjlobpgdicnhkbp) Chrome extension and turn on its Session Replay debug panel on your page. It floats over the page and shows the current replay ID, the session replay SDK and plugin versions, the sample rate, and the default and effective masking settings. A sample rate of 1 in development confirms step 2 took effect, and the effective masking settings show whether Session Replay Settings overrode your code.
5. **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; the debug panel shows the rate the SDK is using. A production build (`npm run build`, then `npm start`) has `import.meta.env.DEV` set to `false` and records only about one session in a hundred at 0.01, so test with `npm run 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: import.meta.env.DEV` 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 in a `useEffect` in `root.tsx` or a route, so the replay and the events disagree on device or session ID. Search the codebase and keep the one in `app/entry.client.tsx`.

### Masked text shows up in a replay

Check four things in order. The text sits in an attribute such as `title` or `aria-label`, which masking never covers. The `data-amp-mask` sits on a component tag and never reached the DOM; inspect the element, then wrap the content in `AmpMask`. The text renders through a portal into `document.body`, outside the masked wrapper. Or the element was unmasked in Session Replay Settings, which overrides the SDK; the debug panel's effective masking settings show it.

### Old replays lose their styling after a deploy

Vite writes CSS to hashed file names under `build/client/assets/`, and a deploy that replaces the build output removes the old ones. Keep previous build assets available on your host, allow `app.amplitude.com` (`app.eu.amplitude.com` for EU projects) in your asset server's CORS policy, and add `crossOrigin="anonymous"` to any external stylesheet link you return from a `links` export.

### 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, and set `serverUrl` (events) and `remoteConfig.serverUrl` (remote configuration) in the `analytics` block.

### No requests to Amplitude at all, and the console says the key is not set

`VITE_AMPLITUDE_API_KEY` is empty in the browser bundle. The name must start with `VITE_`, and Vite reads `.env` only at startup and inlines the value at build time, so restart the dev server, and set it in your build environment before you build for production.

## Full code reference

Every file this tutorial adds or changes, in one block, the version to hand to an agent. 7 files, 155 lines. Shared files start with a comment naming the lines other React Router pages add there; merge into your file and keep those lines. Unchanged from earlier pages; keep your versions: `.env`, `app/root.tsx`, `app/auth.server.ts`, `app/components/SignOutButton.tsx`.

7 files, 155 lines

```typescript
// -- app/entry.client.tsx --
// Keep lines other React Router pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/react-router-web-analytics); the amplitude-cookies import and
// handoff lines, the deviceId spread, and the experiment block
// (/build/react-router-feature-flags); the setAmplitudeReady import, the
// setAmplitudeReady(...) wrapper, and setAmplitudeReady(null)
// (/build/react-router-feature-flags, /build/react-router-guides-surveys); the
// amplitude.add(webExperiment.plugin()) lines (/build/react-router-web-experiment).
// This page changes only sessionReplay.
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import * as amplitude from "@amplitude/unified";

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

if (apiKey) {
  void amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        // Tracks [Amplitude] Start Session and End Session events.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      // Fraction of sessions to record: every session in `npm run dev`, 1% in a production build.
      sampleRate: import.meta.env.DEV ? 1 : 0.01,
      // Ignores the focus check so replays record with DevTools open. Setup only.
      debugMode: import.meta.env.DEV,
      // 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; analytics is off.");
}

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </StrictMode>
  );
});

// -- app/components/AmpMask.tsx --
import type { HTMLAttributes, ReactNode } from "react";

type Props = HTMLAttributes<HTMLElement> & {
  // Replace the content with a same-size placeholder instead of asterisks.
  block?: boolean;
  // Use "span" inside inline content.
  as?: "div" | "span" | "section";
  children: ReactNode;
};

export function AmpMask({ block = false, as: Tag = "div", children, ...rest }: Props) {
  return (
    <Tag
      {...rest}
      data-amp-mask={block ? undefined : ""}
      data-amp-block={block ? "" : undefined}
    >
      {children}
    </Tag>
  );
}

// -- app/routes/team.tsx --
import { AmpMask } from "../components/AmpMask";

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

// Replace with data from your loader.
const members: Member[] = [
  { id: "1", name: "Test User", email: "test@example.com", avatarUrl: "https://placehold.co/32" },
];

export default function Team() {
  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} style={{ display: "flex", gap: "0.5rem" }}>
            {/* Replaced by a same-size placeholder */}
            <AmpMask block as="span">
              <img src={member.avatarUrl} alt="" />
            </AmpMask>
            <span>{member.name}</span>
            {/* Captured as asterisks */}
            <AmpMask as="span">{member.email}</AmpMask>
          </li>
        ))}
      </ul>
    </main>
  );
}

// -- app/routes/billing.tsx --
import { Outlet } from "react-router";
import { AmpMask } from "../components/AmpMask";

// Everything rendered under /billing is masked, including routes added later.
export default function BillingLayout() {
  return (
    <AmpMask>
      <Outlet />
    </AmpMask>
  );
}

// -- app/routes/billing-home.tsx --
export default function BillingHome() {
  return (
    <>
      <h1>Billing</h1>
      <p>Card ending 4242, billed to Test User</p>
    </>
  );
}

// -- app/routes/invoices.tsx --
export default function Invoices() {
  return <h1>Invoices</h1>;
}

// -- app/routes.ts --
// Keep lines other React Router pages added here: their routes
// (/build/react-router-product-analytics, /build/react-router-web-analytics,
// /build/react-router-heatmaps). Merge new entries into the array.
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  // ... your existing routes
  route("team", "routes/team.tsx"),
  route("billing", "routes/billing.tsx", [
    index("routes/billing-home.tsx"),
    route("invoices", "routes/invoices.tsx"),
  ]),
] satisfies RouteConfig;
```

## 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
Complete https://amplitude.com/build/react-router-product-analytics first, or
confirm this React Router framework mode app already has its setup;
create its app/auth.server.ts stub and app/routes/new-project.tsx route
at projects/new if they're missing. Then add Amplitude session replay by following
https://amplitude.com/build/react-router-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, pass the same options to
sessionReplayPlugin({...}), and call amplitude.add() with it before
amplitude.init(). Otherwise use @amplitude/unified, with initAll called
once in app/entry.client.tsx, before hydrateRoot. Merge into existing
shared files (app/entry.client.tsx, app/routes.ts) and never delete lines
another Amplitude Build page added; each file's first comment names them
(experiment block, deviceId handoff, setAmplitudeReady import and calls,
webExperiment plugin, pageViews options). In app/entry.client.tsx change
only sessionReplay: sampleRate import.meta.env.DEV ? 1 : 0.01, debugMode
import.meta.env.DEV, and privacyConfig with [data-amp-mask],
[data-amp-block], and [data-amp-unmask]. Never initialize in root.tsx, a
route module, a loader, or a useEffect. Create app/components/AmpMask.tsx,
a wrapper with block and as props that renders data-amp-mask (or
data-amp-block when block is set) on the element around its children and
spreads the rest of its props onto it. Wrap elements that render emails,
billing names, or account data in it, and wrap images of people or
documents in <AmpMask block>. Put billing and any other sensitive section
under a layout route that renders <AmpMask><Outlet /></AmpMask>, using a
pathless layout if the sections don't share a URL prefix. If the app has
no /team or /billing routes, create the page's demo routes and register
them in app/routes.ts so the Verify steps run: routes/team.tsx, the
routes/billing.tsx layout with routes/billing-home.tsx as its index and
routes/invoices.tsx at invoices. Don't put masking attributes on
component tags, wrap portal content separately, and never use generated
CSS Modules class names as masking selectors. Flag any title, aria-label,
alt, or placeholder that contains personal data. Don't hardcode the key.
Report every file you changed and any Content-Security-Policy 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 frameworksHow to add heatmaps to a React Router appTurn the replay capture you just added into click and scroll maps.](/build/react-router-heatmaps)[Web frameworksHow to add product analytics to a React Router appIdentify signed-in users, so you can find a replay by the user ID from a support ticket.](/build/react-router-product-analytics)[Web frameworksHow to add web analytics to a React Router appAttribute sessions to channels and campaigns, then watch replays from one campaign.](/build/react-router-web-analytics)

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)[Configure session replay sampling →](https://amplitude.com/docs/session-replay/configure-sampling)[Manage privacy settings for session replay →](https://amplitude.com/docs/session-replay/manage-privacy-settings-for-session-replay)[Troubleshoot session replay →](https://amplitude.com/docs/session-replay/troubleshooting)[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-router-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)
