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 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.
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:
@amplitude/unifiedinstalled,VITE_AMPLITUDE_API_KEYin.env,initAllinapp/entry.client.tsx, andgetUser(request)inapp/auth.server.ts(your auth helper or the page's stub). Step 4 usesgetUserfor a sign-in guard. - A sign-in
actionthat calls your auth library and returns session cookie headers; step 4'ssignIn(request)stands in for it. No sign-in yet? Skipapp/routes/login.tsxand itsroutes.tsentry; 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 to get your project API key. Custom Marketing Analytics views need a Growth or Enterprise plan; compare plans.
1. Install the SDK and add the key
The Quickstart installs @amplitude/unified for browser apps that use npm, and the React Router product analytics page 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.
npm install @amplitude/unified2. 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.
// 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.
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'sloader, 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 aloaderand 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 anaction, such as sending people to/welcomeafter 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:
// 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:
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:
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);
}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:
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:
// 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;export default function Pricing() {
return <h1>Pricing</h1>;
}export default function Welcome() {
return <h1>Welcome</h1>;
}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.
- 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. - Confirm the page view event. Open Marketing Analytics, click Customize, and select Page View and Filter. Choose
[Amplitude] Page Viewedas the event. Set a domain filter if the project also receives events from another site. - 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 Viewedfollowed byProject Createdworks as a first funnel. - 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.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server 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:
- Network tab. Turn on Preserve log first. The document request for
/offer/springshould return 307, and itsLocationresponse 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-testwhile signed out: it should return 303 withutm_sourcestill in theLocationheader for/login. If either loses its parameters, the fix is step 4, and nothing downstream will work until it is. Then filter forapi2.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 Chrome extension instead: the[Amplitude] Page Viewedevent for/pricingshould carryutm_source: build-testinevent_properties, and an identify event should setutm_sourceandinitial_utm_sourceas user properties. - 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 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 Viewedper page, and the profile showsinitial_utm_source: build-test. - Marketing Analytics. Open Traffic by Campaign for today.
react-router-web-analyticsshould 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.
// -- 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.
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, already on from step 2, lets you watch what visitors with
utm_campaign: react-router-web-analyticsdid after they landed on/pricing. - Heatmaps, built from those session replays, show where visitors click on
/pricing, and a segment oninitial_utm_sourcesplits paid from organic. - Web Experiment can test a new
/pricinglayout, launched from the Page Engagement table, with your conversion funnel as the metric. - Guides and surveys can ask visitors whose
utm_mediumisemailwhat brought them back.
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.