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

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

  • React Router 7+
  • Framework mode
  • Unified SDK 1.x
  • 10 min

Chris Tam · Last updated 14 October 2026

Remix now ships as React Router's framework mode: loaders, actions, and server rendering, with the browser taking over navigation after the first page. Product analytics shows you what people do once that page is interactive: which routes they visit, what they use, and where they drop off.

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

What you'll build

A React Router app that sends [Amplitude] Page Viewed on every navigation, attaches a user- prefixed user ID and a plan user property once someone signs in, and tracks Project Created with a template property from a /projects/new page. By the end you'll see those events on your test user's profile in Amplitude.

Prerequisites

  • A React Router 7 or later app in framework mode with TypeScript, for example one created with npx create-react-router@latest. This is the current form of Remix. Still on Remix v2? It stopped receiving security fixes on 17 June 2026; move to React Router first with the Remix upgrade guide, then follow this page.
  • Server rendering on, which is the template default (no ssr: false in react-router.config.ts). Step 4 reads the user in a server loader; for an ssr: false app, see Troubleshooting.
  • An auth helper that reads the signed-in user from the request, for example getUser(request) in app/auth.server.ts, returning an object with an id and a plan, or null. No auth yet? Step 4 gives you a stub.
  • An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.

No Amplitude account yet? A free account covers the tracking on this page. Sign up for free to get your project API key. Your session replay capture quota depends on your plan; compare plans.

1. Install the SDK and add the key

The Quickstart installs @amplitude/unified for browser apps that use npm. It bundles analytics, session replay, Feature Experiment, and guides and surveys behind one init call.

Terminal
npm install @amplitude/unified

React Router builds with Vite, which only exposes env vars prefixed with VITE_ to browser code. The 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
# Keep lines other React Router pages added here: VITE_AMPLITUDE_DEPLOYMENT_KEY and
# AMPLITUDE_SERVER_DEPLOYMENT_KEY (/build/react-router-feature-flags).
VITE_AMPLITUDE_API_KEY=your-project-api-key

2. Initialize in the client entry

app/entry.client.tsx is the first code that runs in the browser, and it runs once per page load, before React hydrates the server-rendered HTML. That's where initAll belongs. React Router uses a built-in default until you reveal the file, so reveal it first. This writes app/entry.client.tsx and app/entry.server.tsx; leave the server file as generated.

Terminal
npx react-router reveal

Then add the Amplitude import and the init block above the existing hydrate call. Other React Router pages add lines to this file later; the first comment names them, so you can keep them when you come back here.

app/entry.client.tsx
// Keep lines other React Router pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/react-router-web-analytics); 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).
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) {
  // initAll returns a promise; nothing on this page needs to wait for it.
  void amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        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. root.tsx and every route module also run on the server, where there's no window, and code in a component body runs again on every render. The client entry runs only in the browser and only once, so you get one SDK instance without a guard.

@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. For the full option list, see the Browser Unified SDK reference.

3. Let autocapture handle navigation

There's no code in this step. After the first server-rendered page, React Router handles navigation in the browser. With pageViews: true, the SDK sends [Amplitude] Page Viewed on init and again on every URL change, which covers <Link>, <Form> redirects, and navigate(). Each event carries the page path, title, and URL.

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

4. Identify the signed-in user

Until you set a user ID, events belong to an anonymous device ID. Set it as soon as the page knows who's signed in, and reset only when someone signs out, so the next person on the same browser isn't merged into the last one.

No auth yet? Add this stub so the root loader compiles; every visitor stays anonymous until you replace it with your auth library's lookup.

app/auth.server.ts (stub until you add auth)
export type User = { id: string | number; plan?: string | null };

export async function getUser(_request: Request): Promise<User | null> {
  return null;
}

Return the user from the root loader, so every page has it, then identify from an effect in the root App component. In your existing app/root.tsx, add the loader and replace the default App export. The template already imports Outlet and Route; add only the missing imports, and leave its links, Layout, and ErrorBoundary exports as they are.

app/root.tsx (loader and App export; keep the template's imports, links, Layout and ErrorBoundary)
// Keep lines other React Router pages added here: the refetchBrowserFlags import and
// calls (/build/react-router-feature-flags); useEngagementBridge() and
// <FeedbackButton /> (/build/react-router-guides-surveys); the experiment <script>
// in Layout's head (/build/react-router-web-experiment).

// Add these imports. The template already imports Outlet and the Route types.
import { useEffect, useRef } from "react";
import * as amplitude from "@amplitude/unified";
import { getUser } from "./auth.server";

export async function loader({ request }: Route.LoaderArgs) {
  const user = await getUser(request);
  return {
    user: user ? { id: String(user.id), plan: user.plan ?? "unknown" } : null,
  };
}

export default function App({ loaderData }: Route.ComponentProps) {
  const id = loaderData.user?.id;
  const plan = loaderData.user?.plan ?? "unknown";

  // The root loader reruns after every action. Only act when the
  // identity actually changes.
  const lastIdentity = useRef<string | undefined>(undefined);

  useEffect(() => {
    const identity = id ? `user-${id}|${plan}` : "anonymous";
    if (identity === lastIdentity.current) return;
    lastIdentity.current = identity;

    // Signed out or not signed in yet: keep the same device ID.
    // Reset only from your sign-out handler.
    if (!id) return;

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

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

  return <Outlet />;
}

getUser only runs inside the loader, so React Router keeps auth.server.ts out of the browser bundle. Every page now carries the signed-in user in its loader data, so don't let a shared cache store signed-in pages.

Reset from your sign-out form, not from the root component. If you don't have a sign-out route yet, skip SignOutButton.tsx until you do.

app/components/SignOutButton.tsx
// Keep lines other React Router pages added here: the getEngagement import and
// getEngagement()?.gs.closeAll() before reset() (/build/react-router-guides-surveys);
// amplitude.experiment()?.clear() after reset() (/build/react-router-feature-flags).
// Order: closeAll, reset, clear.
import { Form } from "react-router";
import * as amplitude from "@amplitude/unified";

export function SignOutButton() {
  return (
    <Form
      method="post"
      action="/logout"
      onSubmit={() => {
        // Clears the user ID and issues a new device ID for the next person.
        amplitude.reset();
      }}
    >
      <button type="submit">Sign out</button>
    </Form>
  );
}

Point action at your own sign-out route. Render <SignOutButton /> wherever your signed-in navigation lives.

Why. A null user also means every anonymous visitor and every page where auth hasn't resolved. Calling reset() there issues a new device ID on each of those renders, which splits one visitor into many and breaks device-based flag bucketing later.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy before it writes track calls, so new events match names you already use instead of inventing projectCreated next to Project Created.

5. Track one custom event

Autocapture covers page views, sessions, forms, and clicks. Track the events that mean something to your product yourself, from a handler that only runs in the browser.

app/components/CreateProjectForm.tsx
import { useState } from "react";
import * as amplitude from "@amplitude/unified";

export function CreateProjectForm() {
  const [template, setTemplate] = useState("blank");

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

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

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

Event handlers only run after a click in the browser, so this is safe in a server-rendered route. Don't call track in a component body, a loader, or an action, which run on the server. If creation happens in an action, track after it succeeds, for example when useActionData() or fetcher.data returns the new project, still in the browser. For events your server should send, such as a payment confirmed in an action, use the Node.js SDK there; the Node.js product analytics page covers identity and flushing on the server.

Give the form a page at /projects/new. Later React Router pages link to this route, and the feature flags page replaces its contents with a flag-driven version.

app/routes/new-project.tsx
// Keep lines other React Router pages added here: /build/react-router-feature-flags replaces
// this file with a flag-driven version; if you have that version, keep it.
import { CreateProjectForm } from "../components/CreateProjectForm";

export default function NewProject() {
  return (
    <main>
      <h1>New project</h1>
      <CreateProjectForm />
    </main>
  );
}

Register it in app/routes.ts, next to the template's index route:

app/routes.ts
// Keep lines other React Router pages added here: their routes
// (/build/react-router-web-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 [
  index("routes/home.tsx"),
  route("projects/new", "routes/new-project.tsx"),
] satisfies RouteConfig;

Using flatRoutes() from @react-router/fs-routes instead? Name the file app/routes/projects.new.tsx and skip the routes.ts entry.

Verify it worked

Run npm run dev, open /, then /projects/new, create a project, then check in this order:

  1. Network tab. Filter for api2.amplitude.com. You should see a POST to /2/httpapi returning 200 within about a second of each navigation. Amplitude requests start only once the client entry runs in the browser; the server render sends none.
  2. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads. It shows counts, not individual events.
  3. User lookup. Go to Users > User Profiles and search your user- prefixed test ID. The event stream shows [Amplitude] Page Viewed for each page and Project Created with its template property within ten seconds to a minute, and the profile shows plan. Using the auth stub? Search the device ID instead; the Instrumentation Explorer Chrome extension shows it as device_id on each event; plan appears once a real user signs in.

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

Troubleshooting

The console says "VITE_AMPLITUDE_API_KEY is not set"

Vite reads .env when the dev server starts and inlines the value at build time. Restart the dev server after editing .env, and set the variable in your CI or host's build environment, not only at runtime. The name must start with VITE_.

No requests in the Network tab and no warning in the console

app/entry.client.tsx isn't the file React Router is using, or the template's Layout lost its <Scripts />. The client entry has to sit directly in your app directory (app/ by default); run npx react-router reveal again if you created it by hand somewhere else. Check that app/root.tsx still exports the template's Layout.

"window is not defined" or "document is not defined" during server rendering

Something calls the SDK on the server: an initAll or track at the top level of root.tsx or a route module, or a call inside a loader or action. Keep initAll in app/entry.client.tsx, identity in the useEffect, and track in event handlers.

The build fails with "Could not resolve ./auth.server"

The root loader imports getUser, and the app has no app/auth.server.ts. Add the stub from step 4, or point the import at your auth library's helper.

Two page view events per navigation

A useLocation effect or a page view helper is running alongside autocapture. Delete it.

Signed-in events still show as anonymous

The root component never sees the user. Check that the root loader returns user and that your sign-in action redirects or returns, so React Router revalidates the root loader and the effect reruns.

Signed-in users never get a user ID in an ssr: false app

SPA mode has no request-time server loader: the root loader runs at build time, if at all, with no cookies, so user is always null. Return the user from a root clientLoader that calls your auth client's session endpoint, and read it the same way in App.

Events send but never appear in the project

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

Autocapture does something different from the code

Remote configuration is on by default, so autocapture settings saved in Amplitude under Data > Settings > Autocapture take precedence over the autocapture block in app/entry.client.tsx. To keep autocapture under code control, add remoteConfig: { fetchRemoteConfig: false } to the analytics block.

Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 8 files, 179 lines. Shared files start with a comment naming the lines other React Router pages add there; merge into your file and keep those lines.

8 files, 179 lines
// -- .env --
# Keep lines other React Router pages added here: VITE_AMPLITUDE_DEPLOYMENT_KEY and
# AMPLITUDE_SERVER_DEPLOYMENT_KEY (/build/react-router-feature-flags).
VITE_AMPLITUDE_API_KEY=your-project-api-key

// -- app/entry.client.tsx --
// Keep lines other React Router pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/react-router-web-analytics); 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).
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) {
  // initAll returns a promise; nothing on this page needs to wait for it.
  void amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        attribution: true,
        pageViews: true,
        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/auth.server.ts (stub until you add auth) --
export type User = { id: string | number; plan?: string | null };

export async function getUser(_request: Request): Promise<User | null> {
  return null;
}

// -- app/root.tsx (loader and App export; keep the template's imports, links, Layout and ErrorBoundary) --
// Keep lines other React Router pages added here: the refetchBrowserFlags import and
// calls (/build/react-router-feature-flags); useEngagementBridge() and
// <FeedbackButton /> (/build/react-router-guides-surveys); the experiment <script>
// in Layout's head (/build/react-router-web-experiment).

// Add these imports. The template already imports Outlet and the Route types.
import { useEffect, useRef } from "react";
import * as amplitude from "@amplitude/unified";
import { getUser } from "./auth.server";

export async function loader({ request }: Route.LoaderArgs) {
  const user = await getUser(request);
  return {
    user: user ? { id: String(user.id), plan: user.plan ?? "unknown" } : null,
  };
}

export default function App({ loaderData }: Route.ComponentProps) {
  const id = loaderData.user?.id;
  const plan = loaderData.user?.plan ?? "unknown";

  // The root loader reruns after every action. Only act when the
  // identity actually changes.
  const lastIdentity = useRef<string | undefined>(undefined);

  useEffect(() => {
    const identity = id ? `user-${id}|${plan}` : "anonymous";
    if (identity === lastIdentity.current) return;
    lastIdentity.current = identity;

    // Signed out or not signed in yet: keep the same device ID.
    // Reset only from your sign-out handler.
    if (!id) return;

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

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

  return <Outlet />;
}

// -- app/components/SignOutButton.tsx --
// Keep lines other React Router pages added here: the getEngagement import and
// getEngagement()?.gs.closeAll() before reset() (/build/react-router-guides-surveys);
// amplitude.experiment()?.clear() after reset() (/build/react-router-feature-flags).
// Order: closeAll, reset, clear.
import { Form } from "react-router";
import * as amplitude from "@amplitude/unified";

export function SignOutButton() {
  return (
    <Form
      method="post"
      action="/logout"
      onSubmit={() => {
        // Clears the user ID and issues a new device ID for the next person.
        amplitude.reset();
      }}
    >
      <button type="submit">Sign out</button>
    </Form>
  );
}

// -- app/components/CreateProjectForm.tsx --
import { useState } from "react";
import * as amplitude from "@amplitude/unified";

export function CreateProjectForm() {
  const [template, setTemplate] = useState("blank");

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

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

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

// -- app/routes/new-project.tsx --
// Keep lines other React Router pages added here: /build/react-router-feature-flags replaces
// this file with a flag-driven version; if you have that version, keep it.
import { CreateProjectForm } from "../components/CreateProjectForm";

export default function NewProject() {
  return (
    <main>
      <h1>New project</h1>
      <CreateProjectForm />
    </main>
  );
}

// -- app/routes.ts --
// Keep lines other React Router pages added here: their routes
// (/build/react-router-web-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 [
  index("routes/home.tsx"),
  route("projects/new", "routes/new-project.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
Add Amplitude analytics to this React Router (formerly Remix) app by
following https://amplitude.com/build/react-router-product-analytics exactly.
Install @amplitude/unified. Read VITE_AMPLITUDE_API_KEY from
import.meta.env. Reveal app/entry.client.tsx with
"npx react-router reveal" if it doesn't exist, and call initAll once
there, before hydrateRoot, with autocapture elementInteractions on. Never
call initAll in root.tsx, a route module, a loader, or a useEffect. Don't
add a useLocation page view effect; autocapture tracks navigations.
Merge into existing shared files (app/entry.client.tsx, app/root.tsx,
app/components/SignOutButton.tsx, app/routes.ts) and never delete lines
another Amplitude Build page added; each file's first comment names them.
In app/root.tsx, add the loader and replace only the default App export;
keep the template's imports, links, Layout (with <Scripts />) and
ErrorBoundary. Return the user from the root loader using my existing
auth helper, or create the page's app/auth.server.ts stub (getUser
returns null) if none exists. Set the user ID with a "user-" prefix in a
useEffect in App, skipping reruns where the user ID and plan haven't
changed. Call reset() only from the sign-out form's onSubmit, never when
the user is null. If there's no sign-out route, skip SignOutButton.tsx
and tell me. Set sessionReplay.sampleRate to
import.meta.env.DEV ? 1 : 0.01 and tell me how to turn it off. Only call
track from event handlers. Add the page's Project Created example in
app/components/CreateProjectForm.tsx, create app/routes/new-project.tsx
at projects/new rendering it if it doesn't exist, and register it in
app/routes.ts. Invent no other event names. If the app sets ssr: false,
tell me, and read the user from a root clientLoader instead. Don't
hardcode the key. Report every file you changed.

What this unlocks

  • Session replay, already on from step 2, lets you watch the session where Project Created fired with template: kanban.
  • Heatmaps show where people click on /projects/new before they create a project, built from those same session replays.
  • Feature Experiment, bundled in the same package, can test a new template picker with Project Created as the metric.
  • Guides and surveys, also bundled, can prompt users who viewed /projects/new but never fired Project Created.

One integration; everything else reads from the same events.

Next steps

Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.

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.