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

# How to add web analytics to a React Router app (formerly Remix)

Track page views, sessions, and UTMs in a React Router (formerly Remix) app, keep campaign data through redirects, and read traffic by channel.

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

---

# How to add web analytics to a React Router app (formerly Remix)

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 \`redirect()\` and \`navigate()\`, and open the Marketing Analytics views that read them. About 15 minutes, all copy-paste.

- React Router 7+
- Framework mode
- Unified SDK 1.x
- 15 min

Chris Tam · Last updated 14 October 2026

React Router, the successor to Remix, renders the first page on the server and handles every navigation after that in the browser. [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 loader and action redirects, and set up the channel and campaign views. It builds on [the React Router product analytics page](/build/react-router-product-analytics).

## What you'll build

A React Router app that sends `[Amplitude] Page Viewed` on first load and on every path 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 a loader `redirect()` or a `navigate()` rewrites. By the end you'll see a test campaign in your event stream and a Marketing Analytics view configured to read it.

## 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 `getUser(request)` in `app/auth.server.ts` (your auth helper or the page's stub). Step 4 uses `getUser` for a sign-in guard.
- A sign-in `action` that calls your auth library and returns session cookie headers; step 4's `signIn(request)` stands in for it. No sign-in yet? Skip `app/routes/login.tsx` and its `routes.ts` entry; the other redirects still work.
- 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.

A free account covers the tracking on this page. [Sign up for free](https://app.amplitude.com/signup) to get your project API key. Custom Marketing Analytics views need a Growth or Enterprise plan; [compare plans](https://amplitude.com/pricing).

## 1. Install the SDK and add the key

The [Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) installs `@amplitude/unified` for browser apps that use npm, and the [React Router product analytics page](/build/react-router-product-analytics) uses it, so this setup changes the same `app/entry.client.tsx`. If you already followed that page, the SDK is 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
```

## 2. Configure autocapture for traffic data

Attribution, page views, and sessions are on by default, and the base page already names them in `app/entry.client.tsx`, so nobody turns one off later without knowing what it feeds. This page changes one option: `pageViews` gets `trackHistoryChanges`. Page URL enrichment, which adds current and previous page URL properties, is on by default too, so there's nothing to add for it.

This is the complete file. The first comment names the lines other React Router pages add here; if you followed any of them, keep those lines and change only `pageViews`.

app/entry.client.tsx

```typescript
// Keep lines other React Router pages added here: debugMode and privacyConfig
// (/build/react-router-session-replay); 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 pageViews.
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: {
        // 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.
        pageViews: { trackHistoryChanges: "pathOnly" },
        // [Amplitude] Start Session and End Session. Feeds bounce rate and session length.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      // Every session while you build; about 1% in production.
      sampleRate: import.meta.env.DEV ? 1 : 0.01,
    },
  });
} else {
  console.warn("VITE_AMPLITUDE_API_KEY is not set; analytics is off.");
}

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

**Why.** The client entry runs once in the browser, before React hydrates the server-rendered page, so the SDK reads campaign data from the URL the browser actually loaded. Anything that happened on the server before that, such as a `redirect()` in a loader, already decided which URL that is. That's why step 4 fixes redirects on the server and not here. Keep `initAll` out of `root.tsx`, route modules, and loaders; all of them also run on the server.

`@amplitude/unified` turns on session replay when `sessionReplay.sampleRate` is above 0. This config records every session in development and about 1% in production, which is where Amplitude recommends starting; raise it over a few days. Set it to `0` to turn session replay off. Web analytics doesn't need it; it's here because the base page set it.

From Browser SDK 2.45.0, 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. If the Verify check below shows no `utm_` values in `event_properties`, update `@amplitude/unified` to a release that bundles Browser SDK 2.45.0 or later. 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).

## 3. Let autocapture count navigations

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 in the browser, which covers `<Link>`, `<NavLink>`, `navigate()`, and the redirect after a `<Form>` submission. Each event carries `[Amplitude] Page Path`, `[Amplitude] Page Title`, `[Amplitude] Page URL`, `[Amplitude] Page Location`, 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 calls `setSearchParams({ sort: "price" })` from `useSearchParams` on every filter click records a page view per click, which inflates page views per session and skews entry and exit pages. The same goes for a `<Form method="get">` search box that submits on every keystroke.

Don't add a `useLocation` effect 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 redirects

The SDK can only record campaign data that's in the URL. React Router's `redirect()` goes exactly where you point it, and a plain path has no query string. A visitor clicks a newsletter link to `/account?utm_source=newsletter`, a loader calls `redirect("/login")`, and the browser follows the redirect to a bare `/login`. On that first request the redirect is a real HTTP response, so it happens before any JavaScript runs: the SDK starts on `/login`, never sees the original URL, and records no campaign at all, not even first-touch `initial_utm_source`. Amplitude's docs call this out for single-page apps: UTM parameters can drop during SPA redirects.

Five patterns drop them:

- A `redirect()` in a route's `loader`, such as an auth guard. On a visitor's first request, the loader runs on the server.
- A `redirect()` in a resource route (a route module with a `loader` and no component), such as an old campaign URL you moved.
- A `redirect()` in middleware, which React Router 8 runs by default and React Router 7 runs behind its middleware flag.
- A `redirect()` from an `action`, such as sending people to `/welcome` after sign-in.
- A `navigate("/welcome", { replace: true })` in the browser, which keeps first-touch values but drops the campaign from the event properties of every event after it.

Carry the campaign parameters across with one small helper. It copies only the UTM and click ID parameters the SDK reads, so it never drags unrelated query keys onto the new page:

app/lib/campaign.ts

```typescript
// The UTM and click ID parameters the Browser SDK reads for attribution.
const CAMPAIGN_KEYS = [
  "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id",
  "gclid", "fbclid", "dclid", "gbraid", "wbraid", "ko_click_id",
  "li_fat_id", "msclkid", "rdt_cid", "ttclid", "twclid",
];

// redirect("/login") on its own drops ?utm_source=...
export function withCampaign(path: string, from: URL): string {
  const target = new URL(path, from);
  for (const key of CAMPAIGN_KEYS) {
    const value = from.searchParams.get(key);
    if (value !== null && !target.searchParams.has(key)) {
      target.searchParams.set(key, value);
    }
  return target.pathname + target.search;
}
```

Use it in every guard, passing the incoming request's URL:

app/routes/account.tsx

```typescript
import { Outlet, redirect } from "react-router";
import type { Route } from "./+types/account";
import { getUser } from "../auth.server";
import { withCampaign } from "../lib/campaign";

export async function loader({ request }: Route.LoaderArgs) {
  if (!(await getUser(request))) {
    throw redirect(withCampaign("/login", new URL(request.url)), 303);
  }
  return null;
}

export default function AccountLayout() {
  return (
    <>
      <h1>Account</h1>
      <Outlet />
    </>
  );
}
```

Use it in every resource route that moves an old URL, including ones that add their own query:

app/routes/plans.ts

```typescript
import { redirect } from "react-router";
import type { Route } from "./+types/plans";
import { withCampaign } from "../lib/campaign";

// Old campaign URL, moved to /pricing for good.
export function loader({ request }: Route.LoaderArgs) {
  return redirect(withCampaign("/pricing", new URL(request.url)), 308);
}
```

app/routes/offer.ts

```typescript
import { redirect } from "react-router";
import type { Route } from "./+types/offer";
import { withCampaign } from "../lib/campaign";

// Sets its own query, so the helper merges ?utm_... into it.
export function loader({ params, request }: Route.LoaderArgs) {
  const target = `/pricing?offer=${encodeURIComponent(params.code)}`;
  return redirect(withCampaign(target, new URL(request.url)), 307);
}
```

And in the sign-in action. `signIn` stands in for your auth library's sign-in call; keep your own and change only the redirect line. Leave the `action` prop off the `<Form>` so it posts to the current URL, query string included, which is what puts the campaign parameters on the action's `request.url`:

app/routes/login.tsx

```typescript
import { Form, data, redirect } from "react-router";
import type { Route } from "./+types/login";
import { signIn } from "../auth.server"; // your sign-in logic
import { withCampaign } from "../lib/campaign";

export async function action({ request }: Route.ActionArgs) {
  const headers = await signIn(request); // session cookie headers, or null
  if (!headers) return data({ error: "Wrong email or password" }, 400);

  // request.url is /login?utm_source=..., because the form posts to the current URL.
  throw redirect(withCampaign("/welcome", new URL(request.url)), { status: 303, headers });
}

export default function Login({ actionData }: Route.ComponentProps) {
  return (
    // No action prop: the form posts to /login?utm_source=...
    <Form method="post">
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      {actionData?.error ? <p>{actionData.error}</p> : null}
      <button type="submit">Sign in</button>
    </Form>
  );
}
```

A `<Form action="/login">` with an explicit path drops the current query string before the action runs. If the form must post to another route, carry the current query across: `action={"/session" + useLocation().search}`. If you navigate from the browser instead, wrap the path the same way inside an event handler: `navigate(withCampaign("/welcome", new URL(window.location.href)), { replace: true })`.

In an `ssr: false` app, these redirects run in a `clientLoader`; wrap them with `withCampaign` the same way, using `new URL(request.url)`.

**Why.** Event property tracking re-reads campaign parameters on page load and on every SPA navigation, including the `replaceState` a client-side redirect performs. Whatever is in the URL after the redirect is what gets attached to the events that follow. For the same reason, don't strip UTM parameters from the address bar with `setSearchParams` or `replaceState` to tidy the URL.

Register the new routes in `app/routes.ts`. Keep your existing entries; these are the ones this page adds, plus one-line stub pages for the Verify steps if you don't have `/pricing`, `/welcome`, and an account index yet:

app/routes.ts

```typescript
// Keep lines other React Router pages added here: their routes
// (/build/react-router-product-analytics, /build/react-router-session-replay,
// /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("pricing", "routes/pricing.tsx"),
  route("welcome", "routes/welcome.tsx"),
  route("login", "routes/login.tsx"),
  route("plans", "routes/plans.ts"),
  route("offer/:code", "routes/offer.ts"),
  route("account", "routes/account.tsx", [index("routes/account-home.tsx")]),
] satisfies RouteConfig;
```

app/routes/pricing.tsx

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

app/routes/welcome.tsx

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

app/routes/account-home.tsx

```typescript
export default function AccountHome() {
  return <p>Your account</p>;
}
```

Using `flatRoutes()` from `@react-router/fs-routes` instead? Name the files for the URL (`app/routes/offer.$code.ts`, `app/routes/account._index.tsx`) and skip the `routes.ts` entries. Check each guard, resource route, and action in your app 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 Router product analytics page, `[Amplitude] Page Viewed` followed by `Project Created` works as a first funnel.
4. Read the views. Traffic by Channel, Traffic by Campaign, Page Engagement, and Conversion each show a time series and a table. Page Engagement covers visitors, page views, bounce rate, page views per session, and entry and exit rate per page. 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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) lets your agent look up the events already in your project before it writes `track` calls, so a conversion event matches a name you already use instead of inventing `projectCreated` next to `Project Created`.

## Verify it worked

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

1. **Network tab.** Turn on Preserve log first. The document request for `/offer/spring` should return 307, and its `Location` response header should read `/pricing?offer=spring&utm_source=build-test&utm_medium=email&utm_campaign=react-router-web-analytics`. Then open `/account?utm_source=build-test` while signed out: it should return 303 with `utm_source` still in the `Location` header for `/login`. If either loses its parameters, the fix is step 4, and nothing downstream will work until it is. Then 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](https://amplitude.com/docs/analytics/debug-analytics#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.
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 device ID (the Instrumentation Explorer shows it as `device_id`). Within ten seconds to a minute, the event stream shows `[Amplitude] Start Session`, then one `[Amplitude] Page Viewed` per page, and the profile shows `initial_utm_source: build-test`.
4. **Marketing Analytics.** Open Traffic by Campaign for today. `react-router-web-analytics` should appear as a campaign. It can take a few minutes to appear.

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 empty, so the warning from step 2 is in the console. Vite reads `.env` only when the dev server starts and inlines the value at build time, so restart after editing, and rebuild to change it in production.

### Campaign traffic shows up as Direct

The UTM parameters were gone before the SDK started. Open a `?utm_source=test` URL with Preserve log on and read the `Location` header of every 3xx document response: a guard, resource route, middleware, or action that calls `redirect()` with a bare path is the usual cause. A `<Form>` with an explicit `action` path, or a `navigate()` that doesn't pass the query, does the same in the browser. The Channels docs also note that inconsistent `set()` and `unset()` calls on UTM user properties can classify traffic as Other.

### Two page view events per navigation

A `useLocation` effect or a page view helper is running alongside autocapture. Delete it. If both events are `[Amplitude] Page Viewed`, check that `initAll` is only called from `app/entry.client.tsx`.

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

The page writes state to the query string with `setSearchParams` or a GET `<Form>`, and `trackHistoryChanges` is `'all'`. Set `pageViews: { trackHistoryChanges: 'pathOnly' }` as in step 2.

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

Your routes never set a `<title>`, so every `[Amplitude] Page Title` is the same. Export a `meta` function from each route (`export const meta = () => [{ title: "Pricing" }]`) and keep `<Meta />` in the root `Layout`, or break the view down by `[Amplitude] Page Path` under Customize > Breakdown.

### 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 aren't turned off there. To keep autocapture under code control, add `remoteConfig: { fetchRemoteConfig: false }` to the `analytics` block.

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

An ad blocker is dropping the call. Route events through a proxy on your own domain and set `serverUrl` in the `analytics` block to point at it.

## Full code reference

Every file this tutorial adds or changes, in one block, the version to hand to an agent. 10 files, 169 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`.

10 files, 169 lines

```typescript
// -- app/entry.client.tsx --
// Keep lines other React Router pages added here: debugMode and privacyConfig
// (/build/react-router-session-replay); 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 pageViews.
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: {
        // 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.
        pageViews: { trackHistoryChanges: "pathOnly" },
        // [Amplitude] Start Session and End Session. Feeds bounce rate and session length.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    sessionReplay: {
      // Every session while you build; about 1% in production.
      sampleRate: import.meta.env.DEV ? 1 : 0.01,
    },
  });
} else {
  console.warn("VITE_AMPLITUDE_API_KEY is not set; analytics is off.");
}

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

// -- app/lib/campaign.ts --
// The UTM and click ID parameters the Browser SDK reads for attribution.
const CAMPAIGN_KEYS = [
  "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id",
  "gclid", "fbclid", "dclid", "gbraid", "wbraid", "ko_click_id",
  "li_fat_id", "msclkid", "rdt_cid", "ttclid", "twclid",
];

// redirect("/login") on its own drops ?utm_source=...
export function withCampaign(path: string, from: URL): string {
  const target = new URL(path, from);
  for (const key of CAMPAIGN_KEYS) {
    const value = from.searchParams.get(key);
    if (value !== null && !target.searchParams.has(key)) {
      target.searchParams.set(key, value);
    }
  return target.pathname + target.search;
}

// -- app/routes/account.tsx --
import { Outlet, redirect } from "react-router";
import type { Route } from "./+types/account";
import { getUser } from "../auth.server";
import { withCampaign } from "../lib/campaign";

export async function loader({ request }: Route.LoaderArgs) {
  if (!(await getUser(request))) {
    throw redirect(withCampaign("/login", new URL(request.url)), 303);
  }
  return null;
}

export default function AccountLayout() {
  return (
    <>
      <h1>Account</h1>
      <Outlet />
    </>
  );
}

// -- app/routes/plans.ts --
import { redirect } from "react-router";
import type { Route } from "./+types/plans";
import { withCampaign } from "../lib/campaign";

// Old campaign URL, moved to /pricing for good.
export function loader({ request }: Route.LoaderArgs) {
  return redirect(withCampaign("/pricing", new URL(request.url)), 308);
}

// -- app/routes/offer.ts --
import { redirect } from "react-router";
import type { Route } from "./+types/offer";
import { withCampaign } from "../lib/campaign";

// Sets its own query, so the helper merges ?utm_... into it.
export function loader({ params, request }: Route.LoaderArgs) {
  const target = `/pricing?offer=${encodeURIComponent(params.code)}`;
  return redirect(withCampaign(target, new URL(request.url)), 307);
}

// -- app/routes/login.tsx --
import { Form, data, redirect } from "react-router";
import type { Route } from "./+types/login";
import { signIn } from "../auth.server"; // your sign-in logic
import { withCampaign } from "../lib/campaign";

export async function action({ request }: Route.ActionArgs) {
  const headers = await signIn(request); // session cookie headers, or null
  if (!headers) return data({ error: "Wrong email or password" }, 400);

  // request.url is /login?utm_source=..., because the form posts to the current URL.
  throw redirect(withCampaign("/welcome", new URL(request.url)), { status: 303, headers });
}

export default function Login({ actionData }: Route.ComponentProps) {
  return (
    // No action prop: the form posts to /login?utm_source=...
    <Form method="post">
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      {actionData?.error ? <p>{actionData.error}</p> : null}
      <button type="submit">Sign in</button>
    </Form>
  );
}

// -- app/routes.ts --
// Keep lines other React Router pages added here: their routes
// (/build/react-router-product-analytics, /build/react-router-session-replay,
// /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("pricing", "routes/pricing.tsx"),
  route("welcome", "routes/welcome.tsx"),
  route("login", "routes/login.tsx"),
  route("plans", "routes/plans.ts"),
  route("offer/:code", "routes/offer.ts"),
  route("account", "routes/account.tsx", [index("routes/account-home.tsx")]),
] satisfies RouteConfig;

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

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

// -- app/routes/account-home.tsx --
export default function AccountHome() {
  return <p>Your account</p>;
}
```

## 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 set up Amplitude web analytics by following
https://amplitude.com/build/react-router-web-analytics exactly. 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, including any debugMode or privacyConfig lines, the
experiment block, the device ID handoff, the setAmplitudeReady import and
calls, and the webExperiment plugin. In app/entry.client.tsx change only
analytics.autocapture.pageViews, to { trackHistoryChanges: 'pathOnly' },
and keep initAll called once there, before hydrateRoot. Never initialize
in root.tsx, a route module, a loader, or a useEffect, and don't add a
useLocation page view effect. Add the withCampaign helper in
app/lib/campaign.ts with the page's 6 UTM and 11 click ID keys, then find
every redirect() in loaders, clientLoaders, actions, resource routes, and
middleware, and every navigate() after sign-in, and make each one carry
the UTM and click ID parameters from the incoming request.url or
window.location. Make sign-in forms post to the current URL by leaving
the action prop off <Form>. Add the page's demo routes to app/routes.ts
so the Verify steps run: routes/offer.ts (307 to /pricing?offer=<code>),
routes/plans.ts (308 to /pricing), the routes/account.tsx guard (303 to
/login when getUser returns null), and stub pages for /pricing, /welcome,
and the account index if they don't exist. If the app uses flatRoutes,
name the files for the URL instead. If the app has no sign-in action,
skip routes/login.tsx and its app/routes.ts entry, and tell me. 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), already on from step 2, lets you watch what visitors with `utm_campaign: react-router-web-analytics` did after they landed on `/pricing`.
- [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 frameworksHow to add product analytics to a React Router appIdentify signed-in users and track \`Project Created\`, the event your conversion funnel needs.](/build/react-router-product-analytics)[Web frameworksHow to add session replay to a React Router appWatch the sessions behind your campaign traffic, with masking and sampling tuned for production.](/build/react-router-session-replay)[Web frameworksHow to add Web Experiment to a React Router appTest a page change without a deploy, measured against the same traffic data.](/build/react-router-web-experiment)

Reference docs

[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Browser SDK 2: track marketing attribution →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2#track-marketing-attribution)[Channels →](https://amplitude.com/docs/data/channels)[Out-of-the-box Marketing Analytics →](https://amplitude.com/docs/analytics/ootb-marketing-analytics)[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-router-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)
