Chris Tam · Last updated 14 October 2026
React Router renders every page on the server first, so a feature flag that only resolves in the browser shows the old version. Evaluating it in the route's loader puts the right variant in the first HTML.
In this tutorial, you'll evaluate a flag in a loader, match the server's device ID to the browser's, send exposures from the browser, and fetch browser-only flags after sign-in. It builds on the React Router product analytics page.
What you'll build
A /projects/new route that reads the new-project-flow flag in its loader and renders a template gallery for users in on straight into the server HTML, with no swap after hydration. The browser sends one [Experiment] Exposure per visit, bucketed on the same device ID as the server, even on a first visit. After someone creates a project, a browser-fetched project-checklist flag decides whether they see a checklist, and Project Created with its template property serves as the metric.
Prerequisites
- The app from How to add product analytics to a React Router app, including its root loader and identity effect,
getUserinapp/auth.server.ts, theprojects/newroute, andSignOutButton.tsx. - SSR on, the template default. The server half (steps 2 to 4 and the loader in step 6) needs it. With
ssr: falseinreact-router.config.ts, a loader on/projects/newfails the build; skip the server half and read flags in the browser withuseBrowserFlagfrom step 7. - A long-running Node.js server in production, such as
react-router-serve(the template'snpm start) or an Express server. A function that cold-starts per request downloads the flag rules every time and often renders defaults (step 3). - Access to Experiment in the same Amplitude project your events go to, so you can create deployments and flags.
No Amplitude account yet? Feature flags, JSON payloads, and local evaluation are available on all plans, so a free account covers the steps on this page. Running a flag as an experiment and analyzing the results depends on your plan; compare plans. Sign up for free to get your project API key.
1. Create the deployments and flags
A deployment serves a group of flags through its own key, separate from your project API key. A client deployment key is public and belongs in browser code; a server deployment key is secret. React Router runs code in both places, so this page uses one of each (deployment docs).
Create the two deployments:
- Go to Experiment > Deployments, then click Create Deployment.
- Choose the Amplitude project your analytics events go to, or exposures won't line up with users.
- Name it
react-router-server, choose Server-side as the type, click Create a Deployment, and copy the key. Server keys start withserver-. - Repeat for a second deployment named
web, with Client-side as the type. Client keys start withclient-.
Then create the flag the server renders:
- Go to Experiment > Feature Flags and click Create A Feature Flag.
- Choose the same project and name the flag New project flow. Experiment generates the key
new-project-flow, which your code reads. - Set Evaluation Mode to Local, set the bucketing unit to the user, and click Create.
- Choose the
react-router-serverdeployment from the Deployment drop-down. - Open the default
onvariant that new flags come with. In its Payload field, paste the JSON below, then click Apply. - In the All Other Users section, set bucketed by to Device ID, then set the rollout to 50% so you can see both branches. Save, then set the flag to Active.
Payload for the on variant:
{
"defaultTemplate": "kanban",
"showTemplateGallery": true
}Then create the flag the browser fetches: Project checklist (key project-checklist), Remote evaluation, on the web deployment, with the default on variant, no payload, a 50% rollout, and Active.
Why. Local evaluation downloads the flag rules once and evaluates them in your server process, with no network call per request. It can't resolve the Amplitude ID, which is why the docs for local flags bucket by device ID. That makes steps 4 and 5 essential: if the server and the browser disagree on the device ID, they disagree on the variant. The checklist flag stays remote because it renders only after a click, which leaves the browser time to fetch it.
Don't name a variant off; Experiment reserves it for users who fall outside the rollout. If new-project-flow already exists as a remote flag from another Build page, create a new local flag: changing the evaluation mode changes bucketing, so the docs say to deactivate a flag before you migrate it.
2. Install the server SDK and add the keys
Server-side flags use the Experiment Node.js SDK, separate from the @amplitude/unified package, which already bundles Feature Experiment for the browser.
npm install @amplitude/experiment-node-serverAdd both deployment keys next to the project API key from the product analytics page:
VITE_AMPLITUDE_API_KEY=your-project-api-key
VITE_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key
AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-keyThe two public keys carry the VITE_ prefix, which Vite inlines into the browser bundle; never give the server- key that prefix. A .server.ts module reads it from process.env. react-router dev loads .env for you, but react-router-serve doesn't, so in production set AMPLITUDE_SERVER_DEPLOYMENT_KEY where the server runs and the VITE_ keys where you build. Keep .env out of version control.
3. Start one local evaluation client per server process
This module owns the local evaluation client. The .server.ts suffix keeps it out of the browser bundle, and the build fails if browser code imports it.
import { Experiment } from "@amplitude/experiment-node-server";
type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
export type ServerVariants = ReturnType<LocalClient["evaluateV2"]>;
export type ExperimentUser = {
user_id?: string;
device_id?: string;
user_properties?: Record<string, string>;
};
// Vite re-runs server modules when you edit them in development. Keeping the
// client on globalThis means one client per process, not one per edit.
const store = globalThis as typeof globalThis & {
__amplitudeFlags?: { client: LocalClient; ready: Promise<void> };
};
// Creates the client once per process and starts downloading flag rules.
// The first evaluation calls it; nothing starts when the module loads.
export function startFlags(): Promise<void> {
if (!store.__amplitudeFlags) {
const key = process.env.AMPLITUDE_SERVER_DEPLOYMENT_KEY;
if (!key) return Promise.reject(new Error("AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set"));
const client = Experiment.initializeLocal(key);
// react-router-serve closes its server on SIGTERM and SIGINT but doesn't
// exit, and the flag poller would keep Node running. Stop it there.
for (const signal of ["SIGTERM", "SIGINT"] as const) {
if (process.listenerCount(signal) > 0) process.once(signal, () => client.stop());
}
// start() downloads the flag rules, then polls for updates.
const ready = client.start().catch((error) => {
// Stop polling for the dropped client; the next request retries.
client.stop();
if (store.__amplitudeFlags?.client === client) store.__amplitudeFlags = undefined;
throw error;
});
store.__amplitudeFlags = { client, ready };
}
return store.__amplitudeFlags.ready;
}
// Evaluates the given flags for one user in memory. Returns {} on any
// failure, so routes render their defaults instead of erroring.
export async function evaluateFlags(
user: ExperimentUser,
flagKeys: string[],
): Promise<ServerVariants> {
if (!user.user_id && !user.device_id) return {};
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const started = startFlags();
const current = store.__amplitudeFlags?.client;
// A cold start or outage renders defaults instead of holding the page.
await Promise.race([
started,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("flag rules not loaded within 1 s")), 1000);
}),
]);
return current ? current.evaluateV2(user, flagKeys) : {};
} catch (error) {
console.error("[flags] evaluation failed; rendering defaults.", error);
return {};
} finally {
clearTimeout(timer);
}
}Why. Flag rules are the same for everyone and refresh every 30 seconds by default, so one client lives per process, stopping its poller when the server shuts down, and each request only evaluates, in about 0.025 ms per the docs. evaluateV2 only reads rules that are already loaded, so every call waits for start(), but never more than a second. The client starts on the first evaluation, not at module load, because react-router build with prerender imports the server build and a poller started there can keep the build from exiting. The first request after a deploy may render defaults; later requests catch up, and a failed start() is retried on the next request. On a custom Express server, you can call startFlags() after listen to warm the rules up.
The Node.js local evaluation page covers streaming updates, cohort targeting, and server-side exposure tracking, and the Node.js feature flags page covers remote evaluation on a server.
4. Evaluate with the browser's device ID
The browser SDK stores its device ID in the AMP_ cookie, and the Node.js SDK's AmplitudeCookie class parses it on the server. On a first visit there's no cookie yet, so the server creates a device ID, evaluates with it, and hands it to the browser in a short-lived cookie of its own for step 5.
Server and browser code share the handoff cookie's name and a cookie reader:
// Set by the server when it evaluates flags for a visitor who has no
// AMP_ cookie yet. Not the SDK's own cookie.
export const DEVICE_ID_HANDOFF_COOKIE = "amp_server_device_id";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isDeviceId(value: string | undefined): value is string {
return value !== undefined && UUID.test(value);
}
// Reads one cookie from a Cookie header or document.cookie.
export function readCookie(cookies: string, name: string): string | undefined {
const prefix = `${name}=`;
const match = cookies.split(/;\s*/).find((c) => c.startsWith(prefix));
if (!match) return undefined;
// A malformed cookie must not stop the page from rendering or hydrating.
try {
return decodeURIComponent(match.slice(prefix.length));
} catch {
return undefined;
}
}loadFlags is the one function a route's loader calls, in step 6. It returns the flags and the headers the response needs:
import { randomUUID } from "node:crypto";
import { AmplitudeCookie } from "@amplitude/experiment-node-server";
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId, readCookie } from "./amplitude-cookies";
import type { ServerFlags } from "./flags";
import { evaluateFlags } from "./experiment.server";
type FlagUser = { id: string | number; plan?: string | null } | null;
function readDeviceId(cookies: string): string | undefined {
const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;
if (!apiKey) return undefined;
// cookieName throws on a malformed key: render defaults, not an error.
try {
// `true` selects the Browser SDK 2 cookie format, which unified uses.
const cookie = readCookie(cookies, AmplitudeCookie.cookieName(apiKey, true));
return cookie ? (AmplitudeCookie.parse(cookie, true).device_id ?? undefined) : undefined;
} catch {
return undefined;
}
}
export async function loadFlags(
request: Request,
user: FlagUser,
flagKeys: string[],
): Promise<{ flags: ServerFlags; headers: Headers }> {
// This response carries one visitor's device ID and variants. No cache may store it.
const headers = new Headers({ "Cache-Control": "private, no-store" });
const cookies = request.headers.get("Cookie") ?? "";
// 1. The browser SDK's own cookie.
// 2. An ID this server minted earlier that the browser hasn't adopted yet.
// 3. A new ID, handed to the browser for step 5.
let deviceId = readDeviceId(cookies);
if (!deviceId) {
const handoff = readCookie(cookies, DEVICE_ID_HANDOFF_COOKIE);
if (isDeviceId(handoff)) deviceId = handoff;
}
if (!deviceId) {
deviceId = randomUUID();
// Not HttpOnly: the client entry reads it.
headers.append(
"Set-Cookie",
`${DEVICE_ID_HANDOFF_COOKIE}=${deviceId}; Path=/; Max-Age=3600; SameSite=Lax`,
);
}
// The same "user-" prefix the browser sets, so both sides send one ID.
const userId = user ? `user-${user.id}` : undefined;
const variants = await evaluateFlags(
{
device_id: deviceId,
user_id: userId,
// Local evaluation only sees the properties you pass in.
...(user ? { user_properties: { plan: user.plan ?? "unknown" } } : {}),
},
flagKeys,
);
// Plain objects only, and nothing for users outside the rollout.
const flags: ServerFlags["flags"] = {};
for (const [flagKey, variant] of Object.entries(variants)) {
if (variant?.value && variant.value !== "off") {
flags[flagKey] = { value: variant.value, payload: variant.payload };
}
}
return { flags: { deviceId, userId, flags }, headers };
}Why. AmplitudeCookie.cookieName builds the AMP_ cookie name from your API key. The server sets its own handoff cookie and lets the browser SDK write AMP_ in step 5, so it never has to match the SDK's cookie format and domain. Reusing the handoff ID covers a second request that arrives before the browser JavaScript runs, so one visitor never gets two IDs.
5. Adopt the server's device ID in the client entry
The SDK's initAll returns a promise, and experiment() and getDeviceId() return undefined until it settles. Steps 6 and 7 need both, so first add a small module that holds that promise for the rest of the app. The React Router guides and surveys page uses the same file; if you already have it, keep yours.
// Resolves to true once initAll has finished in the browser, or false
// if analytics is off. Nothing awaits it on the server.
let markReady: (ok: boolean) => void = () => {};
export const amplitudeReady = new Promise<boolean>((resolve) => {
markReady = resolve;
});
export function setAmplitudeReady(init: Promise<unknown> | null) {
if (!init) {
markReady(false);
return;
}
init.then(
() => markReady(true),
(error) => {
console.warn("[amplitude] initAll failed", error);
markReady(false);
},
);
}Then update app/entry.client.tsx, keeping the lines its first comment names. What changed: it reads the handoff cookie and passes it as analytics.deviceId when the browser has no AMP_ cookie yet, it hands the initAll promise to setAmplitudeReady, and it adds the experiment block that step 7 uses.
// 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 setAmplitudeReady(...) wrapper and setAmplitudeReady(null) (/build/react-router-guides-surveys),
// and 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";
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId, readCookie } from "./lib/amplitude-cookies";
import { setAmplitudeReady } from "./lib/amplitude-ready";
const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;
if (apiKey) {
// Browser SDK 2 names its cookie AMP_ plus the first 10 characters of
// the key. If it exists, the SDK already has a device ID: keep it.
const hasAmpCookie = readCookie(document.cookie, `AMP_${apiKey.slice(0, 10)}`) !== undefined;
const serverDeviceId = readCookie(document.cookie, DEVICE_ID_HANDOFF_COOKIE);
const adoptServerDeviceId = !hasAmpCookie && isDeviceId(serverDeviceId);
// Steps 6 and 7 wait for this promise before they read from the SDK.
setAmplitudeReady(
amplitude.initAll(apiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
// First visit only: the device ID the server bucketed this visitor
// with. Later visits read it from the AMP_ cookie the SDK writes.
...(adoptServerDeviceId ? { deviceId: serverDeviceId } : {}),
},
sessionReplay: {
// Every session while you build; about 1% in production.
sampleRate: import.meta.env.DEV ? 1 : 0.01,
debugMode: import.meta.env.DEV,
privacyConfig: {
maskSelector: ["[data-amp-mask]"],
blockSelector: ["[data-amp-block]"],
unmaskSelector: ["[data-amp-unmask]"],
},
},
experiment: {
// The client deployment key, for the browser flags in step 7.
deploymentKey: import.meta.env.VITE_AMPLITUDE_DEPLOYMENT_KEY,
// Default is 10 seconds. Fall back to the current UI sooner.
fetchTimeoutMillis: 3000,
},
}),
);
} else {
console.warn("VITE_AMPLITUDE_API_KEY is not set; analytics is off.");
setAmplitudeReady(null);
}
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
});Why. The client entry runs once, before hydration, and the Set-Cookie from step 4 arrived with the page, so the handoff cookie is readable. deviceId is passed only when the browser has no AMP_ cookie, so a stale handoff never replaces a device ID the browser already has; from then on the SDK writes its own cookie and every loader reads it. setAmplitudeReady runs on both paths, so code that waits on amplitudeReady never hangs; it gets false when analytics is off or initAll fails.
The sessionReplay block is unchanged from the session replay page; set sampleRate to 0 to turn session replay off.
6. Render the variant from the loader and send one exposure
app/lib/flags.ts holds the flag hooks. This first part reads a server-evaluated variant during render and sends the exposure after mount. Step 7 adds the browser half.
import { useEffect, useRef } from "react";
import * as amplitude from "@amplitude/unified";
import { amplitudeReady } from "./amplitude-ready";
export type FlagVariant = { value: string; payload?: unknown };
export type ServerFlags = {
deviceId: string;
userId?: string;
flags: Record<string, FlagVariant>;
};
export function useServerFlag(state: ServerFlags | null | undefined, flagKey: string) {
const variant = state?.flags[flagKey];
const value = variant?.value;
const deviceId = state?.deviceId;
const userId = state?.userId;
const lastExposure = useRef<string | undefined>(undefined);
// Browser only, after the variant is on screen.
useEffect(() => {
// No variant means the user isn't in the rollout. An exposure without
// a variant would remove them from the experiment, so send nothing.
if (!deviceId || !value) return;
// Once per visit and identity, even when the loader data reloads or
// StrictMode runs the effect twice.
const key = `${userId ?? ""}|${deviceId}|${value}`;
if (key === lastExposure.current) return;
lastExposure.current = key;
// getDeviceId() is undefined until initAll settles.
void amplitudeReady.then((ok) => {
if (!ok) return;
const browserDeviceId = amplitude.getDeviceId();
const browserUserId = amplitude.getUserId();
// A variant evaluated for another device or user isn't this
// visitor's: send nothing.
const sameDevice = browserDeviceId === deviceId;
const sameUser = !userId || !browserUserId || browserUserId === userId;
if (!sameDevice || !sameUser) {
if (import.meta.env.DEV) {
console.warn(
`[flags] no exposure for ${flagKey}: the browser's device or user differs from what the server evaluated.`,
);
}
return;
}
amplitude.track({
event_type: "$exposure",
event_properties: { flag_key: flagKey, variant: value },
// The user the server evaluated, if the root effect hasn't set it yet.
...(userId && !browserUserId ? { user_id: userId } : {}),
});
});
}, [flagKey, deviceId, userId, value]);
return variant;
}The template list lives in its own module, so the route and the form share it:
export const TEMPLATES: string[] = ["blank", "kanban", "roadmap"];The loader calls loadFlags and the component reads the flag from the loader data, so the first render, on the server and in the browser, has the payload's settings. This replaces the contents of app/routes/new-project.tsx, which the product analytics page registered at projects/new, so app/routes.ts doesn't change:
// Replaces the version from /build/react-router-product-analytics, which rendered
// CreateProjectForm. No other React Router page adds lines here.
import { data } from "react-router";
import type { Route } from "./+types/new-project";
import { getUser } from "../auth.server";
import { loadFlags } from "../lib/flags.server";
import { useServerFlag } from "../lib/flags";
import { TEMPLATES } from "../lib/templates";
import { NewProjectForm } from "../components/NewProjectForm";
// Only flags this route renders. Every key here ends up in the page's
// serialized loader data, where anyone can read it in the page source.
const FLAG_KEYS = ["new-project-flow"];
type NewProjectPayload = {
defaultTemplate?: unknown;
showTemplateGallery?: unknown;
};
export async function loader({ request }: Route.LoaderArgs) {
const user = await getUser(request);
const { flags, headers } = await loadFlags(request, user, FLAG_KEYS);
return data({ amplitude: flags }, { headers });
}
// React Router sends a loader's Set-Cookie on its own, but no other loader
// header unless the route's headers export returns it.
export function headers({ loaderHeaders }: Route.HeadersArgs) {
return loaderHeaders;
}
export default function NewProject({ loaderData }: Route.ComponentProps) {
const variant = useServerFlag(loaderData.amplitude, "new-project-flow");
// Only the "on" variant carries the new settings. Validate the payload
// and keep the current defaults for anything missing or unexpected.
const config = (variant?.value === "on" ? variant.payload : undefined) as
| NewProjectPayload
| undefined;
const candidate = config?.defaultTemplate;
const defaultTemplate =
typeof candidate === "string" && TEMPLATES.includes(candidate) ? candidate : "blank";
const showGallery = config?.showTemplateGallery === true;
// The key recreates the form if a loader revalidation changes the variant.
return (
<NewProjectForm
key={`${defaultTemplate}|${showGallery}`}
defaultTemplate={defaultTemplate}
showGallery={showGallery}
/>
);
}import { useState } from "react";
import * as amplitude from "@amplitude/unified";
import { TEMPLATES } from "../lib/templates";
import { ProjectChecklist } from "./ProjectChecklist";
type Props = { defaultTemplate: string; showGallery: boolean };
export function NewProjectForm({ defaultTemplate, showGallery }: Props) {
// Seeded once, when the form is created.
const [template, setTemplate] = useState(defaultTemplate);
const [created, setCreated] = useState(false);
async function handleCreate() {
// ... your create logic
amplitude.track("Project Created", { template });
setCreated(true);
}
return (
<>
{showGallery ? (
<fieldset>
<legend>Start from a template</legend>
{TEMPLATES.map((t) => (
<label key={t}>
<input
type="radio"
name="template"
value={t}
checked={template === t}
onChange={() => setTemplate(t)}
/>
{t}
</label>
))}
</fieldset>
) : (
<select value={template} onChange={(e) => setTemplate(e.target.value)}>
{TEMPLATES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
)}
<button type="button" onClick={handleCreate}>
Create project
</button>
{created ? <ProjectChecklist /> : null}
</>
);
}CreateProjectForm from the product analytics page stays in your app; this route renders NewProjectForm instead.
Why. The server render and hydration read the same loader data, so nothing swaps after load. The server could track the exposure itself (exposureConfig on initializeLocal), but crawlers and abandoned loads also trigger server renders, and the event tracking docs reserve server-side heuristics for cases where client-side exposure tracking isn't possible. The exposure waits for amplitudeReady, since a first visit mounts the route before initAll settles, and it's skipped when the browser's device or user no longer matches what the server evaluated. If the browser has no user ID yet, the exposure carries the server's user_id. Call useServerFlag only in the route that shows the variant; in root.tsx it would count visitors who never opened /projects/new.
The headers export is the React Router trap on this page. Without it, the Set-Cookie still goes out, but Cache-Control: private, no-store doesn't, and a shared cache can hand one visitor's device ID and variant to everyone. If a parent route also sets Cache-Control, the deepest route's headers win, so keep this one on the leaf route.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's flags and events first, so it reads new-project-flow instead of inventing newProjectFlow.
7. Fetch browser flags after identity, clear them on sign-out
Server-evaluated flags already follow sign-in and sign-out, because React Router reruns loaders after every action. Browser flags need their own fetch. This half of app/lib/flags.ts counts settled fetches, and useBrowserFlag reads the variant after mount:
import { useState, useSyncExternalStore } from "react";
// useEffect, amplitude, and amplitudeReady are already imported above.
type FetchUser = { user_properties?: Record<string, string> };
// Goes up by one each time a browser flag fetch settles. 0 means the
// first fetch hasn't settled yet.
let flagsVersion = 0;
let fetchGeneration = 0;
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function refetchBrowserFlags(user?: FetchUser) {
const generation = ++fetchGeneration;
try {
// experiment() is undefined until initAll has finished.
if (await amplitudeReady) {
// On failure or timeout the SDK returns and retries in the background.
await amplitude.experiment()?.fetch(user);
}
} finally {
// Only the newest fetch tells components to re-read, so a slow anonymous
// fetch from page load doesn't re-render them after sign-in.
if (generation === fetchGeneration) {
flagsVersion += 1;
listeners.forEach((listener) => listener());
}
}
}
export function useBrowserFlag(flagKey: string) {
// The server snapshot is 0, so nothing reads a variant during server render.
const version = useSyncExternalStore(subscribe, () => flagsVersion, () => 0);
const [flag, setFlag] = useState<{ value: string | null; payload: unknown }>();
// Runs after mount and whenever a fetch settles. variant() tracks an
// exposure, so only components that show the variant call useBrowserFlag.
useEffect(() => {
if (version === 0) return;
const variant = amplitude.experiment()?.variant(flagKey);
setFlag({ value: variant?.value ?? null, payload: variant?.payload });
}, [flagKey, version]);
return { value: flag?.value ?? null, payload: flag?.payload };
}The checklist mounts only after someone creates a project:
import { useBrowserFlag } from "../lib/flags";
export function ProjectChecklist() {
const checklist = useBrowserFlag("project-checklist");
if (checklist.value !== "on") return null;
return (
<aside>
<h2>Next steps</h2>
<ul>
<li>Invite your team</li>
<li>Import your existing tasks</li>
</ul>
</aside>
);
}Fetch from the root identity effect, after the user ID is set. In app/root.tsx, add one import and the two refetchBrowserFlags calls to the App export. Everything else in the file stays:
// Keep lines other React Router pages added here: the root loader and its imports
// (/build/react-router-product-analytics), useEngagementBridge() and <FeedbackButton />
// (/build/react-router-guides-surveys), and the <script> in Layout's head (/build/react-router-web-experiment).
// Add this import:
import { refetchBrowserFlags } from "./lib/flags";
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;
if (!id) {
// Anonymous: keep the device ID. Reset only from the sign-out handler.
void refetchBrowserFlags();
return;
}
// Amplitude drops IDs shorter than 5 characters. Same string as the server.
amplitude.setUserId(`user-${id}`);
const identify = new amplitude.Identify();
identify.set("plan", plan);
amplitude.identify(identify);
// Pass plan with the request, so targeting sees it on this evaluation.
void refetchBrowserFlags({ user_properties: { plan } });
}, [id, plan]);
return <Outlet />;
}Then clear the previous user's variants on sign-out. Add the clear() line to app/components/SignOutButton.tsx, after reset():
// Keep lines other React Router pages added here: the getEngagement import and
// getEngagement()?.gs.closeAll() before reset() (/build/react-router-guides-surveys).
// 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();
// Drops the previous user's browser variants.
amplitude.experiment()?.clear();
}}
>
<button type="submit">Sign out</button>
</Form>
);
}Why. The docs recommend fetching again when the user signs in, passing new user properties with the request, because a property Amplitude only just received can race the evaluation. onSubmit runs before React Router submits the form, so the loaders that rerun after logout read the new AMP_ cookie.
useBrowserFlag reads in an effect, so variant() and the exposure it tracks only run in the browser. Don't use it for a flag the server renders, or the browser's answer can differ from the HTML.
Verify it worked
Run npm run build, then AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key npm start, so you're testing a production render; react-router-serve doesn't read .env. While you verify, raise both flags' rollouts to 100% so your test user lands in on; the server picks up the local flag's new rollout within 30 seconds. Open /projects/new in a private window, signed out, and check step 1 on that first load; then sign in through your existing sign-in route and continue:
- Network tab. Select the document request for
/projects/new. Its response headers includecache-control: private, no-store, and on the first load in the private window, aset-cookieforamp_server_device_id. Its response contains Start from a template, and the console shows no hydration warning. Filter forapi2.amplitude.com: a POST to/2/httpapireturns 200 after the page renders. Filter forlab.amplitude.com: requests toapi.lab.amplitude.comgo out after load and again after sign-in. - 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. Within ten seconds to a minute, the event stream shows[Experiment] Exposurewith[Experiment] Flag Keyset tonew-project-flowand[Experiment] Variantset toon. Create a project:Project Createdappears withtemplate: kanban, followed by a second[Experiment] Exposureforproject-checklist. Amplitude renames$exposureto[Experiment] Exposureon ingestion. - Device ID match. Open a new private window and load
/projects/newdirectly, signed out. In the Instrumentation Explorer Chrome extension (the SDK compresses larger request bodies, so the Network tab payload is often unreadable), find the$exposureevent withflag_keynew-project-flow: itsdevice_idequals theamp_server_device_idvalue under Application > Cookies. Reload, and the document response no longer sets the handoff cookie, because the server now reads the browser'sAMP_cookie.
Set both rollouts back to 50% when you're done.
Troubleshooting
The server never returns a variant
The flag isn't in Local mode, on the react-router-server deployment, and Active, the rollout doesn't cover your test device, or AMPLITUDE_SERVER_DEPLOYMENT_KEY holds a client- key. Check the terminal for the [flags] evaluation failed error. To watch the rules load, temporarily pass { logLevel: LogLevel.Debug } to initializeLocal, importing LogLevel from @amplitude/experiment-node-server.
The build fails with a loader error on /projects/new
The app sets ssr: false in react-router.config.ts, and the server half needs SSR. Turn SSR on, or drop the loader and read the flag with useBrowserFlag (step 7). Browser flags work either way.
Every visitor gets the same variant, or the same device ID
A cache, possibly a CDN "cache everything" rule, is storing a route whose loader calls loadFlags. Check that the document response carries cache-control: private, no-store; if it doesn't, the route is missing the headers export from step 6, or a deeper child route's headers export replaces it. Don't call loadFlags from a route you prerender in react-router.config.ts: the prerendered HTML is shared by every visitor.
The console warns that no exposure was sent for a flag
The browser's device or user doesn't match what the server evaluated, so no exposure is sent until the next full load. Confirm app/entry.client.tsx passes deviceId only when there's no AMP_ cookie and the handoff holds a UUID, and that nothing calls setDeviceId or reset() on load. The pattern assumes the default identityStorage: 'cookie'; with localStorage, the server mints a new ID on every request. A first visit that navigates from an unflagged route to a flagged one within the first second can also do this once. If a session ended without the sign-out button, run the same reset() and clear() wherever your app ends a session.
No exposure is sent, and no warning appears
useServerFlag checks and sends only after amplitudeReady resolves to true. It resolves to false when VITE_AMPLITUDE_API_KEY is empty or initAll fails, and never resolves if app/entry.client.tsx doesn't call setAmplitudeReady. Check the console for the missing-key warning or [amplitude] initAll failed. If initAll failed because a content blocker stopped the guides and surveys or session replay script, flags stop too; if you don't use guides and surveys, add engagement: { skip: true } to the initAll options so it never loads.
The checklist never appears, even at 100%
Confirm project-checklist is Active and on the web deployment, and that VITE_AMPLITUDE_DEPLOYMENT_KEY holds the key that starts with client-, not the server key. If the variable is empty, the SDK falls back to the project API key's default deployment, which doesn't serve flags on web. If no request to api.lab.amplitude.com goes out at all, amplitudeReady never resolved to true; see "No exposure is sent" above.
Requests go out but nothing shows in the project
EU projects need serverZone: 'EU' at the top level of the initAll options and serverZone: 'eu' in the initializeLocal options, with keys from the EU project.
Full code reference
Every file this tutorial adds or changes, in one block, the version to hand to an agent. 13 files, 489 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: app/routes.ts, app/auth.server.ts, app/entry.server.tsx, app/routes/home.tsx, app/components/CreateProjectForm.tsx, and the imports, root loader, links, Layout and ErrorBoundary in app/root.tsx.
// -- .env --
VITE_AMPLITUDE_API_KEY=your-project-api-key
VITE_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key
AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key
// -- app/lib/experiment.server.ts --
import { Experiment } from "@amplitude/experiment-node-server";
type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
export type ServerVariants = ReturnType<LocalClient["evaluateV2"]>;
export type ExperimentUser = {
user_id?: string;
device_id?: string;
user_properties?: Record<string, string>;
};
const store = globalThis as typeof globalThis & {
__amplitudeFlags?: { client: LocalClient; ready: Promise<void> };
};
export function startFlags(): Promise<void> {
if (!store.__amplitudeFlags) {
const key = process.env.AMPLITUDE_SERVER_DEPLOYMENT_KEY;
if (!key) return Promise.reject(new Error("AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set"));
const client = Experiment.initializeLocal(key);
// react-router-serve closes its server on SIGTERM and SIGINT but doesn't
// exit, and the flag poller would keep Node running. Stop it there.
for (const signal of ["SIGTERM", "SIGINT"] as const) {
if (process.listenerCount(signal) > 0) process.once(signal, () => client.stop());
}
const ready = client.start().catch((error) => {
client.stop();
if (store.__amplitudeFlags?.client === client) store.__amplitudeFlags = undefined;
throw error;
});
store.__amplitudeFlags = { client, ready };
}
return store.__amplitudeFlags.ready;
}
export async function evaluateFlags(
user: ExperimentUser,
flagKeys: string[],
): Promise<ServerVariants> {
if (!user.user_id && !user.device_id) return {};
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const started = startFlags();
const current = store.__amplitudeFlags?.client;
await Promise.race([
started,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("flag rules not loaded within 1 s")), 1000);
}),
]);
return current ? current.evaluateV2(user, flagKeys) : {};
} catch (error) {
console.error("[flags] evaluation failed; rendering defaults.", error);
return {};
} finally {
clearTimeout(timer);
}
}
// -- app/lib/amplitude-cookies.ts --
export const DEVICE_ID_HANDOFF_COOKIE = "amp_server_device_id";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isDeviceId(value: string | undefined): value is string {
return value !== undefined && UUID.test(value);
}
export function readCookie(cookies: string, name: string): string | undefined {
const prefix = `${name}=`;
const match = cookies.split(/;\s*/).find((c) => c.startsWith(prefix));
if (!match) return undefined;
try {
return decodeURIComponent(match.slice(prefix.length));
} catch {
return undefined;
}
}
// -- app/lib/flags.server.ts --
import { randomUUID } from "node:crypto";
import { AmplitudeCookie } from "@amplitude/experiment-node-server";
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId, readCookie } from "./amplitude-cookies";
import type { ServerFlags } from "./flags";
import { evaluateFlags } from "./experiment.server";
type FlagUser = { id: string | number; plan?: string | null } | null;
function readDeviceId(cookies: string): string | undefined {
const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;
if (!apiKey) return undefined;
try {
const cookie = readCookie(cookies, AmplitudeCookie.cookieName(apiKey, true));
return cookie ? (AmplitudeCookie.parse(cookie, true).device_id ?? undefined) : undefined;
} catch {
return undefined;
}
}
export async function loadFlags(
request: Request,
user: FlagUser,
flagKeys: string[],
): Promise<{ flags: ServerFlags; headers: Headers }> {
const headers = new Headers({ "Cache-Control": "private, no-store" });
const cookies = request.headers.get("Cookie") ?? "";
let deviceId = readDeviceId(cookies);
if (!deviceId) {
const handoff = readCookie(cookies, DEVICE_ID_HANDOFF_COOKIE);
if (isDeviceId(handoff)) deviceId = handoff;
}
if (!deviceId) {
deviceId = randomUUID();
headers.append(
"Set-Cookie",
`${DEVICE_ID_HANDOFF_COOKIE}=${deviceId}; Path=/; Max-Age=3600; SameSite=Lax`,
);
}
const userId = user ? `user-${user.id}` : undefined;
const variants = await evaluateFlags(
{
device_id: deviceId,
user_id: userId,
...(user ? { user_properties: { plan: user.plan ?? "unknown" } } : {}),
},
flagKeys,
);
const flags: ServerFlags["flags"] = {};
for (const [flagKey, variant] of Object.entries(variants)) {
if (variant?.value && variant.value !== "off") {
flags[flagKey] = { value: variant.value, payload: variant.payload };
}
}
return { flags: { deviceId, userId, flags }, headers };
}
// -- app/lib/amplitude-ready.ts --
let markReady: (ok: boolean) => void = () => {};
export const amplitudeReady = new Promise<boolean>((resolve) => {
markReady = resolve;
});
export function setAmplitudeReady(init: Promise<unknown> | null) {
if (!init) {
markReady(false);
return;
}
init.then(
() => markReady(true),
(error) => {
console.warn("[amplitude] initAll failed", error);
markReady(false);
},
);
}
// -- 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 setAmplitudeReady(...) wrapper and setAmplitudeReady(null) (/build/react-router-guides-surveys),
// and 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";
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId, readCookie } from "./lib/amplitude-cookies";
import { setAmplitudeReady } from "./lib/amplitude-ready";
const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;
if (apiKey) {
const hasAmpCookie = readCookie(document.cookie, `AMP_${apiKey.slice(0, 10)}`) !== undefined;
const serverDeviceId = readCookie(document.cookie, DEVICE_ID_HANDOFF_COOKIE);
const adoptServerDeviceId = !hasAmpCookie && isDeviceId(serverDeviceId);
setAmplitudeReady(
amplitude.initAll(apiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: true,
sessions: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
...(adoptServerDeviceId ? { deviceId: serverDeviceId } : {}),
},
sessionReplay: {
sampleRate: import.meta.env.DEV ? 1 : 0.01,
debugMode: import.meta.env.DEV,
privacyConfig: {
maskSelector: ["[data-amp-mask]"],
blockSelector: ["[data-amp-block]"],
unmaskSelector: ["[data-amp-unmask]"],
},
},
experiment: {
deploymentKey: import.meta.env.VITE_AMPLITUDE_DEPLOYMENT_KEY,
fetchTimeoutMillis: 3000,
},
}),
);
} else {
console.warn("VITE_AMPLITUDE_API_KEY is not set; analytics is off.");
setAmplitudeReady(null);
}
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>
);
});
// -- app/lib/flags.ts --
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import * as amplitude from "@amplitude/unified";
import { amplitudeReady } from "./amplitude-ready";
export type FlagVariant = { value: string; payload?: unknown };
export type ServerFlags = {
deviceId: string;
userId?: string;
flags: Record<string, FlagVariant>;
};
export function useServerFlag(state: ServerFlags | null | undefined, flagKey: string) {
const variant = state?.flags[flagKey];
const value = variant?.value;
const deviceId = state?.deviceId;
const userId = state?.userId;
const lastExposure = useRef<string | undefined>(undefined);
useEffect(() => {
if (!deviceId || !value) return;
const key = `${userId ?? ""}|${deviceId}|${value}`;
if (key === lastExposure.current) return;
lastExposure.current = key;
void amplitudeReady.then((ok) => {
if (!ok) return;
const browserDeviceId = amplitude.getDeviceId();
const browserUserId = amplitude.getUserId();
const sameDevice = browserDeviceId === deviceId;
const sameUser = !userId || !browserUserId || browserUserId === userId;
if (!sameDevice || !sameUser) {
if (import.meta.env.DEV) {
console.warn(
`[flags] no exposure for ${flagKey}: the browser's device or user differs from what the server evaluated.`,
);
}
return;
}
amplitude.track({
event_type: "$exposure",
event_properties: { flag_key: flagKey, variant: value },
...(userId && !browserUserId ? { user_id: userId } : {}),
});
});
}, [flagKey, deviceId, userId, value]);
return variant;
}
type FetchUser = { user_properties?: Record<string, string> };
let flagsVersion = 0;
let fetchGeneration = 0;
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function refetchBrowserFlags(user?: FetchUser) {
const generation = ++fetchGeneration;
try {
if (await amplitudeReady) {
await amplitude.experiment()?.fetch(user);
}
} finally {
if (generation === fetchGeneration) {
flagsVersion += 1;
listeners.forEach((listener) => listener());
}
}
}
export function useBrowserFlag(flagKey: string) {
const version = useSyncExternalStore(subscribe, () => flagsVersion, () => 0);
const [flag, setFlag] = useState<{ value: string | null; payload: unknown }>();
useEffect(() => {
if (version === 0) return;
const variant = amplitude.experiment()?.variant(flagKey);
setFlag({ value: variant?.value ?? null, payload: variant?.payload });
}, [flagKey, version]);
return { value: flag?.value ?? null, payload: flag?.payload };
}
// -- app/lib/templates.ts --
export const TEMPLATES: string[] = ["blank", "kanban", "roadmap"];
// -- app/routes/new-project.tsx --
// Replaces the version from /build/react-router-product-analytics, which rendered
// CreateProjectForm. No other React Router page adds lines here.
import { data } from "react-router";
import type { Route } from "./+types/new-project";
import { getUser } from "../auth.server";
import { loadFlags } from "../lib/flags.server";
import { useServerFlag } from "../lib/flags";
import { TEMPLATES } from "../lib/templates";
import { NewProjectForm } from "../components/NewProjectForm";
const FLAG_KEYS = ["new-project-flow"];
type NewProjectPayload = {
defaultTemplate?: unknown;
showTemplateGallery?: unknown;
};
export async function loader({ request }: Route.LoaderArgs) {
const user = await getUser(request);
const { flags, headers } = await loadFlags(request, user, FLAG_KEYS);
return data({ amplitude: flags }, { headers });
}
export function headers({ loaderHeaders }: Route.HeadersArgs) {
return loaderHeaders;
}
export default function NewProject({ loaderData }: Route.ComponentProps) {
const variant = useServerFlag(loaderData.amplitude, "new-project-flow");
const config = (variant?.value === "on" ? variant.payload : undefined) as
| NewProjectPayload
| undefined;
const candidate = config?.defaultTemplate;
const defaultTemplate =
typeof candidate === "string" && TEMPLATES.includes(candidate) ? candidate : "blank";
const showGallery = config?.showTemplateGallery === true;
return (
<NewProjectForm
key={`${defaultTemplate}|${showGallery}`}
defaultTemplate={defaultTemplate}
showGallery={showGallery}
/>
);
}
// -- app/components/NewProjectForm.tsx --
import { useState } from "react";
import * as amplitude from "@amplitude/unified";
import { TEMPLATES } from "../lib/templates";
import { ProjectChecklist } from "./ProjectChecklist";
type Props = { defaultTemplate: string; showGallery: boolean };
export function NewProjectForm({ defaultTemplate, showGallery }: Props) {
const [template, setTemplate] = useState(defaultTemplate);
const [created, setCreated] = useState(false);
async function handleCreate() {
// ... your create logic
amplitude.track("Project Created", { template });
setCreated(true);
}
return (
<>
{showGallery ? (
<fieldset>
<legend>Start from a template</legend>
{TEMPLATES.map((t) => (
<label key={t}>
<input
type="radio"
name="template"
value={t}
checked={template === t}
onChange={() => setTemplate(t)}
/>
{t}
</label>
))}
</fieldset>
) : (
<select value={template} onChange={(e) => setTemplate(e.target.value)}>
{TEMPLATES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
)}
<button type="button" onClick={handleCreate}>
Create project
</button>
{created ? <ProjectChecklist /> : null}
</>
);
}
// -- app/components/ProjectChecklist.tsx --
import { useBrowserFlag } from "../lib/flags";
export function ProjectChecklist() {
const checklist = useBrowserFlag("project-checklist");
if (checklist.value !== "on") return null;
return (
<aside>
<h2>Next steps</h2>
<ul>
<li>Invite your team</li>
<li>Import your existing tasks</li>
</ul>
</aside>
);
}
// -- app/root.tsx (App export only; keep the template's imports, links, Layout and ErrorBoundary) --
// Keep lines other React Router pages added here: the root loader and its imports
// (/build/react-router-product-analytics), useEngagementBridge() and <FeedbackButton />
// (/build/react-router-guides-surveys), and the <script> in Layout's head (/build/react-router-web-experiment).
// Add this import:
import { refetchBrowserFlags } from "./lib/flags";
export default function App({ loaderData }: Route.ComponentProps) {
const id = loaderData.user?.id;
const plan = loaderData.user?.plan ?? "unknown";
const lastIdentity = useRef<string | undefined>(undefined);
useEffect(() => {
const identity = id ? `user-${id}|${plan}` : "anonymous";
if (identity === lastIdentity.current) return;
lastIdentity.current = identity;
if (!id) {
void refetchBrowserFlags();
return;
}
amplitude.setUserId(`user-${id}`);
const identify = new amplitude.Identify();
identify.set("plan", plan);
amplitude.identify(identify);
void refetchBrowserFlags({ user_properties: { plan } });
}, [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).
// 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={() => {
amplitude.reset();
amplitude.experiment()?.clear();
}}
>
<button type="submit">Sign out</button>
</Form>
);
}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 has its setup, including the
app/auth.server.ts stub and the projects/new route; create them if
missing. Then follow https://amplitude.com/build/react-router-feature-flags
exactly, using its Full code reference. I'll create the react-router-server and
web deployments and the new-project-flow (local) and project-checklist
(remote) flags; ask me for the keys. Put the server key in
AMPLITUDE_SERVER_DEPLOYMENT_KEY (no VITE_ prefix, read only in .server.ts
modules) and the client key in VITE_AMPLITUDE_DEPLOYMENT_KEY. Start the
local evaluation client lazily on the first evaluation, never at module
load, and wait at most 1000 ms before rendering defaults. The
new-project.tsx loader returns loadFlags' headers through a headers
export. Send $exposure from the browser only, and only when the browser's
device ID equals the server's and the user ID matches or is unset. Never
read a server-rendered flag with experiment().variant(), never call
reset() when the user is null, and never import a .server module into a
component. Merge into existing shared files (app/entry.client.tsx,
app/root.tsx, app/components/SignOutButton.tsx) and never delete lines
another Amplitude Build page added; each file's first comment names them.
Don't hardcode keys or invent flag or event names. Report every file you
changed.What this unlocks
- Feature Experiment can run
new-project-flowas an A/B test with[Experiment] Exposureas the exposure event andProject Createdas the primary metric, counting only people whose browser rendered the gallery. - Session replay can filter to users with the
[Experiment] new-project-flowuser property, and each replay shows the server-rendered variant from the first frame. - Heatmaps on
/projects/new, built from those replays, never mix gallery clicks with clicks on a loading state. - Guides and surveys can target the
[Experiment] project-checklistuser property to ask whether the checklist helped.
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.