Chris Tam · Last updated 14 October 2026
React Router maps every route in app/routes.ts to a URL. Heatmaps show where people click and how far they scroll on each of those pages, so you see which parts of a screen get used and which get skipped.
In this tutorial, you'll confirm session replay, which heatmaps are built from, give each settings tab its own route, and create your first heatmap. It builds on the React Router product analytics page.
What you'll build
A React Router app that records session replay on every route, sends [Amplitude] Page Viewed with the same device_id as the replay, keeps any masking rules from the session replay page, and gives the Profile and Billing tabs of /settings their own routes, plus a click map in Amplitude for every /projects/:projectId page. By the end you'll see a replay of your test session and a heatmap set up to fill in once traffic crosses the data threshold.
Prerequisites
- A React Router 7 or later app in framework mode. Still on Remix v2? Move to React Router first with the Remix upgrade guide.
- The setup from How to add product analytics to a React Router app:
initAllinapp/entry.client.tsx, plus the identity effect in the rootAppcomponent. - An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
- Access to heatmaps in Amplitude. Any user with View permissions can create a heatmap; turning on heatmap capture for a project takes an Admin or Manager role.
Heatmaps are available on the Plus, Growth, and Enterprise plans, for customers with session replay; compare plans. No Amplitude account yet? Sign up for free to get your project API key.
Amplitude recommends a separate development project for testing heatmaps, so your test clicks stay out of production data. Heatmap and session replay events don't count toward your event volume.
1. Install the SDK
The 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 Amplitude keeps the device_id on replay and analytics events aligned for you, which heatmap segment and cohort filters depend on. If you followed the React Router product analytics page, 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.
npm install @amplitude/unifiedAlready on @amplitude/analytics-browser to keep bundle size down? Add the Session Replay Browser SDK Plugin to your existing init instead of installing a second SDK. It takes the same options as the sessionReplay block in your initAll call.
Heatmaps need Session Replay Browser SDK Plugin 1.7.0 or later. The Verify section shows how to read the version your page actually runs.
2. Confirm session replay is on
No code change. Session replay's default sample rate is 0, which records nothing, and recording is what heatmaps are built from. The base page's sessionReplay.sampleRate: import.meta.env.DEV ? 1 : 0.01 in app/entry.client.tsx already sets it above 0, which is all heatmaps need, so keep that file as it is, with every line other React Router pages added to it. Before you raise the rate, add the masking from the React Router session replay page, because heatmap backgrounds are replay snapshots and unmasked text shows up in them.
Why. Initializing more than once can leave replay and analytics events with different device or session IDs. Heatmaps still draw an unsegmented map in that case, but segment and cohort filters come back empty, because Amplitude can't join the clicks to your users. The client entry runs once per page load, in the browser only. Never add a second initAll in a component's useEffect, which runs again whenever that component mounts and twice under StrictMode in development, or anywhere a loader or route module runs on the server.
You don't need elementInteractions for heatmaps. The heatmap prerequisites list session replay, not click autocapture; the clicks on the map come from the replay. The base page turns it on for click events in product analytics, so it stays. pageUrlEnrichment is on by default with autocapture, so the base file doesn't list it; leave it on. It adds [Amplitude] Page URL to your own track events, so you can chart them per route next to the heatmap.
Be deliberate about the sample rate. Every heatmap reflects only the sessions you captured, so at 0.01 a map is built from 1% of traffic and takes longer to cross the data threshold. Amplitude recommends starting low and raising the rate over a few days; capture stops for the month once your org's replay quota runs out. Session Replay Settings in Amplitude can also set the project's rate remotely, so check there when the rate you see doesn't match your code.
Unified also initializes guides and surveys by default. If you won't use them, add engagement: { skip: true } to the initAll options, as the Unified SDK docs describe.
3. Turn on heatmap capture in Amplitude
The SDK records replays, but the project decides whether heatmap data gets collected. In Amplitude, go to Settings > Organizational Settings > Session Replay Settings. The project list shows a session capture toggle and a heatmap capture toggle for each project. Turn both on for this project.
Select the project to open its Sampling control. A rate set there applies to the project without a deploy, which is the easier place to raise capture once heatmaps need more data.
4. Give each tab its own route
Heatmaps compute data per URL string, not per view. A tab bar that swaps content with useState and conditional rendering never changes the URL, so the Profile and Billing tabs of /settings end up in one heatmap, drawn over whichever tab the background happened to catch. React Router fixes this with nested routes: each tab becomes a child route of /settings, and the tab bar lives in a layout route that renders the active tab through <Outlet />.
import { NavLink, Outlet } from "react-router";
export default function SettingsLayout() {
return (
<section>
<h1>Settings</h1>
<nav>
<NavLink to="/settings/profile">Profile</NavLink>
<NavLink to="/settings/billing">Billing</NavLink>
</nav>
{/* The active tab renders here. */}
<Outlet />
</section>
);
}export default function SettingsProfile() {
return <h2>Profile</h2>;
}export default function SettingsBilling() {
return <h2>Billing</h2>;
}If the Billing tab shows account data, wrap its content in <AmpMask> from the React Router session replay page; heatmaps still record the clicks, and the background shows masked text.
Send /settings on its own to the first tab, so it never becomes a page of its own:
import { redirect } from "react-router";
export function loader() {
// No campaign links land here. For guards that can receive one, see withCampaign on /build/react-router-web-analytics.
return redirect("/settings/profile", 307);
}
// Never renders; the loader always redirects. Keeps this a UI route, not a resource route.
export default function SettingsIndex() {
return null;
}The heatmap in step 5 targets a dynamic project route. If your app doesn't have a project screen yet, add a minimal one:
import type { Route } from "./+types/project";
export function loader({ params }: Route.LoaderArgs) {
return { projectId: params.projectId };
}
export default function Project({ loaderData }: Route.ComponentProps) {
return <h1>Project {loaderData.projectId}</h1>;
}Register them in app/routes.ts, next to your existing entries:
// Keep lines other React Router pages added here: their routes
// (/build/react-router-product-analytics, /build/react-router-web-analytics,
// /build/react-router-session-replay). Merge new entries into the array.
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
// ... your existing routes
route("settings", "routes/settings.tsx", [
index("routes/settings-index.tsx"),
route("profile", "routes/settings-profile.tsx"),
route("billing", "routes/settings-billing.tsx"),
]),
route("projects/:projectId", "routes/project.tsx"),
] satisfies RouteConfig;Using flatRoutes() instead? Name the files settings.tsx, settings._index.tsx, settings.profile.tsx, settings.billing.tsx, and projects.$projectId.tsx, and skip the routes.ts entries. The links and the redirect assume your app has no basename in react-router.config.ts; <NavLink> adds one for you, but the redirect target needs it written in. These loaders only return route data; they never call the SDK. The settings layout renders inside your root App, so the identity effect from the base page keeps running on every tab.
Now /settings/profile and /settings/billing get separate heatmaps, and each tab switch also sends its own [Amplitude] Page Viewed. The redirect runs in the index route's loader before React Router updates the URL, on a direct visit and on a client-side navigation alike, so /settings on its own never records a page view.
Why. Put the tab in the path, not in a query string such as /settings?tab=billing read with useSearchParams. Heatmaps need the [Amplitude] Page URL property to match URLs, and that property is the page URL with query parameters removed, so /settings?tab=profile and /settings?tab=billing can land in the same heatmap. A path segment is unambiguous. Modals and drawers that don't change the URL also merge into the page underneath; if one matters, give it a route the same way.
React Router serves /settings/billing and /settings/billing/ as the same route without redirecting either. Heatmap data is keyed by the URL string, so links that mix the two forms split one page's data across two URLs, and an Exact match filter has to use whichever form your links produce. Write links without the trailing slash, and keep them that way once heatmaps are collecting.
5. Create your first heatmap
Heatmaps appear after Amplitude collects enough replay click data across your site: at least 10 separate pages, with about 500 tracked replay clicks on each page. These thresholds are approximate. Create the heatmap now and it fills in once traffic crosses them.
- In Amplitude, click Heatmaps in the left navigation, then + New Heatmap.
- Choose Click map. You can switch to a selector map or scroll map at any time.
- Select the URL.
projects/:projectIdgives every project its own URL, so use Pattern match withhttps://your-domain.com/projects/*to combine them into one map. That pattern also matches/projects/newfrom the product analytics page, so the new-project form's clicks land in the same map. Keep that in mind when you read it, or move the form to a path outside/projects/if you want project pages mapped on their own. Use Exact match for a single page, such ashttps://your-domain.com/settings/billing, and Starts with for a section, such ashttps://your-domain.com/settings. - Optionally pick a segment of users by user property or cohort.
- Pick a device type. Heatmaps group data into four screen-width buckets: greater than 1440px, 900 to 1440px, 500 to 899px, and less than 500px. A responsive layout that collapses its nav into a menu below a CSS breakpoint looks different in each bucket, and the background and the click data must fall in the same one.
- Choose a background. Each heatmap generates eight, taken from the page states with the most actions. Skip any that caught an open modal or a loading spinner.
Once your data meets the thresholds, Amplitude also creates a few heatmaps automatically for your highest-traffic pages. You can delete them; they aren't recreated.
If your routes carry personal data in the path, such as an email address in users/:email, configure URL filter rules (ugcFilterRules inside interactionConfig, with enabled: true; see Filter sensitive URL content) to replace those URLs before session replay and heatmaps record them.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy before it adds track calls near a heatmap hotspot, so new events match names you already use instead of inventing planUpgraded next to Plan Upgraded.
Verify it worked
A development session can't reach the heatmap data threshold, so verification stops at confirming that replays arrive from a new enough SDK and that each tab has its own URL. Run npm run dev, open http://localhost:5173 in a fresh window, click between /settings/profile, /settings/billing, and a project page, then check in this order:
- Network tab. Filter for
amplitude. You should see event uploads toapi2.amplitude.comreturning 200, a request tosr-client-cfg.amplitude.com, and replay uploads toapi-sr.amplitude.com. None of them come from the server render; they start once the client entry runs in the browser. - Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads. It shows counts, not individual events.
- User lookup. Go to Users > User Profiles and search your
user-prefixed test ID, or your device ID (the Instrumentation Explorer Chrome extension shows it asdevice_id) if you aren't signed in. Within ten seconds to a minute, the event stream shows one[Amplitude] Page Viewedper route you visited, and the[Amplitude] Page URLon the two settings views ends in/settings/profileand/settings/billing. A Play Session button appears for the session once the replay is processed, generally about five minutes after the session begins. - Session Replay debug panel. In the Instrumentation Explorer Chrome extension, turn on the Session Replay debug panel. It shows the current replay ID, the session replay SDK and plugin versions, the sample rate, and the masking settings. The plugin version must be 1.7.0 or later, and the sample rate must not be 0.
- Heatmaps. Open the heatmap from step 5. In a development project it shows "No heatmap available" until the thresholds are met. After you deploy, confirm the live site serves the updated SDK, not only your local build.
Troubleshooting
The heatmap is blank or says "No heatmap available"
Work through the causes in order: Amplitude is still processing new replay data (a few minutes after install), the URL filter doesn't match URLs in your events, the deployed site runs a session replay plugin older than 1.7.0, or traffic hasn't reached about 500 replay clicks on each of 10 pages. If [Amplitude] Page URL was hidden, restore it in Data > Properties.
Every tab on a screen shows up in one heatmap
The tabs share a URL. Move each one to its own child route as in step 4. A tab kept in a query string can merge too, because [Amplitude] Page URL drops query parameters; move it into the path.
No replays at all, even with a sample rate of 1
Check the project's rate and capture toggles in Session Replay Settings (step 3). Then open the console. If you send a Content-Security-Policy from a route's headers export, app/entry.server.tsx, or your host, a blocked request appears there; add the hosts from step 5 of the React Router session replay page, which lists the Amplitude hosts session replay, analytics, and guides and surveys need, including the *.amplitude.com hosts guides and surveys load by default. If remote configuration fails to load, session replay captures nothing, on purpose. An ad blocker also stops capture; test in a clean profile.
A background shows no data
The background's viewport width falls in a different screen-width bucket from your recorded sessions. Use Change Screenshot > From a Replay and pick a screenshot taken at a width where your data exists.
Segment or cohort filters return nothing
Replay and analytics events carry different device_id values, so Amplitude can't join clicks to users. Look for a second initAll or init call, for example in a useEffect in root.tsx, or a standalone session replay SDK running beside your analytics. Keep the single init in app/entry.client.tsx.
"Heatmaps isn't available for your organization"
Your organization predates Amplitude's current packaging and needs manual enablement. Contact Amplitude Support.
Full code reference
Every file this tutorial adds or changes, in one block: the version to hand to an agent. 6 files, 65 lines. app/routes.ts starts with a comment naming the routes other React Router pages add there; add these entries next to them.
// -- app/routes/settings.tsx --
import { NavLink, Outlet } from "react-router";
export default function SettingsLayout() {
return (
<section>
<h1>Settings</h1>
<nav>
<NavLink to="/settings/profile">Profile</NavLink>
<NavLink to="/settings/billing">Billing</NavLink>
</nav>
<Outlet />
</section>
);
}
// -- app/routes/settings-profile.tsx --
export default function SettingsProfile() {
return <h2>Profile</h2>;
}
// -- app/routes/settings-billing.tsx --
export default function SettingsBilling() {
return <h2>Billing</h2>;
}
// -- app/routes/settings-index.tsx --
import { redirect } from "react-router";
export function loader() {
return redirect("/settings/profile", 307);
}
export default function SettingsIndex() {
return null;
}
// -- app/routes/project.tsx --
import type { Route } from "./+types/project";
export function loader({ params }: Route.LoaderArgs) {
return { projectId: params.projectId };
}
export default function Project({ loaderData }: Route.ComponentProps) {
return <h1>Project {loaderData.projectId}</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-session-replay). Merge new entries into the array.
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
// ... your existing routes
route("settings", "routes/settings.tsx", [
index("routes/settings-index.tsx"),
route("profile", "routes/settings-profile.tsx"),
route("billing", "routes/settings-billing.tsx"),
]),
route("projects/:projectId", "routes/project.tsx"),
] satisfies RouteConfig;
// Unchanged from earlier pages; keep your versions: .env, app/entry.client.tsx, app/root.tsx, app/components/SignOutButton.tsx, app/auth.server.ts, app/routes/new-project.tsx.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.
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 heatmaps by
following https://amplitude.com/build/react-router-heatmaps exactly. Merge into
existing shared files and never delete lines another Amplitude Build
page added. Don't change app/entry.client.tsx: confirm it has a single
@amplitude/unified initAll before hydrateRoot with a sessionReplay
sampleRate above 0 (import.meta.env.DEV ? 1 : 0.01), and never add a
second initAll in a useEffect, a loader, or any server code. Tell me how
to turn session replay off, and tell me to add the masking from
https://amplitude.com/build/react-router-session-replay before I raise the
rate. Find every screen that switches tabs with component state or a
query string and give each tab its own child route under a layout route
with <Outlet />, as the page does for settings: routes/settings.tsx with
NavLink tabs, routes/settings-profile.tsx, routes/settings-billing.tsx,
and routes/settings-index.tsx redirecting /settings to /settings/profile
with a 307. If the app has no project screen, add routes/project.tsx at
projects/:projectId. Add the entries to app/routes.ts next to the
existing ones, or name the files for the URL if the app uses
flatRoutes. Write links without trailing slashes. Remind me to turn on
heatmap capture in Session Replay Settings. Don't hardcode the key and
don't invent event names. Report every file you changed.What this unlocks
- Session replay, already recording from the base page, lets you select an area of a click map and watch the sessions behind those clicks.
- Product analytics can chart
[Amplitude] Page Viewedfor/settings/billingagainst your upgrade event to see whether the tab you just mapped converts. - Feature Experiment, bundled in
@amplitude/unified, can test a new billing layout, and a heatmap per variant shows how the clicks moved. - Guides and surveys, also bundled, can point users to a button the click map shows they miss.
One integration; everything else reads from the same events.
Next steps
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.