---
title: "How to add guides and surveys to a React Router app (formerly Remix)"
description: "Show Amplitude guides and surveys in a React Router (formerly Remix) app: one init, guide links through navigate(), stable pins, and a survey button."
canonical: "https://amplitude.com/en-us/build/react-router-guides-surveys"
---

# How to add guides and surveys to a React Router app (formerly Remix)

Show Amplitude guides and surveys in a React Router (formerly Remix) app: one init, guide links through navigate(), stable pins, and a survey button.

Source: https://amplitude.com/en-us/build/react-router-guides-surveys

---

# How to add guides and surveys to a React Router app (formerly Remix)

Turn on guides and surveys in a React Router app, send guide links and buttons through React Router's \`navigate()\`, anchor a pin to a component, and open a survey from your own button. About 20 minutes, all copy-paste.

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

Chris Tam · Last updated 14 October 2026

React Router renders each page on the server, then React Router moves between pages in the browser without a reload. [Guides and surveys](https://amplitude.com/guides-and-surveys) walk users through those pages and ask for feedback in context, and you edit them in Amplitude instead of shipping a deploy.

In this tutorial, you'll turn on guides and surveys, route guide links through `navigate()`, anchor a pin, and open a survey. It builds on [the React Router product analytics page](/build/react-router-product-analytics).

## What you'll build

A React Router app where guides and surveys boot once, in the browser, with the same user and device ID as your analytics, guide links marked Use router navigate with React Router, a guide button runs an `open_new_project` callback in your code, a pin anchors to `[data-guide="new-project-button"]`, and a "Give feedback" button opens the survey with the key `product-feedback`. By the end you'll see the SDK report a booted user, and `Guide Viewed` and `Survey Submitted` in your own user's event stream.

## Prerequisites

- A React Router 7 or later app in framework mode, with Amplitude already initialized in `app/entry.client.tsx` and the user identified from the root `App` component in `app/root.tsx`. If you don't have that yet, start with [How to add product analytics to a React Router app](/build/react-router-product-analytics). Still on Remix v2? Move to React Router first with the [Remix upgrade guide](https://reactrouter.com/upgrading/remix).
- Your Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General. Guides and surveys must use the same key as your analytics.
- The root `loader`, `app/auth.server.ts`, `app/components/SignOutButton.tsx`, and the `/projects/new` route from the product analytics page. The guide's callback and the home page link go to `/projects/new`.

Guides and surveys availability depends on your plan; [compare plans](https://amplitude.com/pricing). No Amplitude account yet? [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Install the SDK and turn guides and surveys on

The [Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) installs `@amplitude/unified` for browser apps that use npm, and the Browser Unified SDK includes guides and surveys and initializes them by default. If you followed the [product analytics page](/build/react-router-product-analytics), it's already installed and `.env` holds `VITE_AMPLITUDE_API_KEY`. Otherwise install it, then add the key to `.env` as that page's step 1 shows.

Terminal

```sh
npm install @amplitude/unified
```

Then turn the product on in Amplitude. The [Guides and Surveys Web SDK docs](https://amplitude.com/docs/sdks/guides-and-surveys/sdk) say to enable guides and surveys in your project settings before anything can display, and the project-level apps setting has to include web so it appears as a target when you create a guide or survey. Open Project Settings > Guides and Surveys and turn on both.

### If your app sends security headers

Skip this if your app doesn't send a Content-Security-Policy or Cross-Origin-Opener-Policy header; a new React Router app sends neither. If yours does, from a route's `headers` export, `app/entry.server.tsx`, or your host, the docs require two changes. Add the Amplitude hosts to the directives you already have:

Content-Security-Policy (merge into your existing directives)

```text
script-src 'self' https://*.amplitude.com
connect-src 'self' https://*.amplitude.com
img-src 'self' https://*.amplitude.com
media-src 'self' https://*.amplitude.com
style-src 'self' https://*.amplitude.com
```

Set Cross-Origin-Opener-Policy to `same-origin-allow-popups`, not `same-origin`, which blocks the messages Preview mode passes between Amplitude and your page, so previews never open. If you also run Web Experiment, its Visual Editor needs `unsafe-none`, which works for Preview too.

If your `style-src` blocks inline styles, guides and surveys can't draw. Either add `'unsafe-inline'` to `style-src`, or, if your app already generates a per-request nonce (passed to `<ServerRouter nonce>` and `<Scripts nonce>`), list that nonce in `style-src` too and give it to the SDK's `nonce` option, which lets it apply its inline styles. With `@amplitude/unified`, guides and surveys options go in the `engagement` block of `initAll`. The client entry can't see the request, so read the nonce React Router already rendered on its own scripts: add `engagement: { nonce: document.querySelector<HTMLScriptElement>("script[nonce]")?.nonce }` to the `initAll` options in step 2. The `nonce` property works where `getAttribute("nonce")` returns an empty string, because browsers hide the attribute after load. If your security policy forbids `'unsafe-inline'` and you don't have a nonce setup yet, add one before you rely on guides and surveys.

## 2. Initialize once in the client entry

Keep `initAll` where the product analytics page put it: `app/entry.client.tsx`, which runs once in the browser before React hydrates the page. The init options don't change. The one addition hands the `initAll` promise to a small module, so code that talks to guides and surveys can wait until it has finished. The SDK's `initAll` returns a promise, and `window.engagement` isn't ready to use until it settles.

This is the same ready module as the React Router feature flags page. If you already have it, keep yours.

app/lib/amplitude-ready.ts

```typescript
// 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);
    },
  );
}
```

`window.engagement` isn't in TypeScript's `Window` type, and `window` doesn't exist during the server render. Handle both in one typed accessor that lists only the methods this page calls and returns `undefined` on the server.

app/lib/engagement.ts

```typescript
// The window.engagement methods this page calls, as the Guides and Surveys Web SDK docs define them.
type Engagement = {
  setRouter(fn: (url: string) => void): void;
  addCallback(name: string, fn: () => void): void;
  gs: {
    show(key: string, stepIndex?: number): void;
    closeAll(): void;
  };
  _debugStatus?: () => unknown;
};

export function getEngagement(): Engagement | undefined {
  // No window during the server render.
  if (typeof window === "undefined") return undefined;
  return (window as unknown as { engagement?: Engagement }).engagement;
}
```

Merge the file below into your `app/entry.client.tsx`. This page changes only the `setAmplitudeReady` import and the two calls to it. The first comment names the lines other React Router pages add to this file; keep them where you have them.

app/entry.client.tsx

```typescript
// Keep lines other React Router pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/react-router-web-analytics); debugMode and privacyConfig
// (/build/react-router-session-replay); the amplitude-cookies import and handoff
// lines, the deviceId spread, and the experiment block
// (/build/react-router-feature-flags); the webExperiment.plugin() registration after
// initAll (/build/react-router-web-experiment).
// This page adds the setAmplitudeReady import, the setAmplitudeReady(...) wrapper around initAll, and setAmplitudeReady(null).
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import * as amplitude from "@amplitude/unified";
import { setAmplitudeReady } from "./lib/amplitude-ready";

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

if (apiKey) {
  // initAll starts analytics, session replay, Feature Experiment, and
  // guides and surveys. Guides and surveys boot with the analytics user.
  setAmplitudeReady(
    amplitude.initAll(apiKey, {
      analytics: {
        autocapture: {
          attribution: true,
          pageViews: true,
          sessions: true,
          formInteractions: true,
          fileDownloads: true,
          elementInteractions: true,
        },
      sessionReplay: {
        // Every session while you build; about 1% in production.
        sampleRate: import.meta.env.DEV ? 1 : 0.01,
      },
    }),
  );
} else {
  console.warn("VITE_AMPLITUDE_API_KEY is not set; analytics is off.");
  setAmplitudeReady(null);
}

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

**Why.** With the Unified SDK, guides and surveys initialize alongside analytics and boot with the same user and session. Don't call `engagement.init()` or `engagement.boot()` yourself, and don't initialize from a component's `useEffect`. The docs say calling boot more than once causes unexpected behavior, especially for guides and surveys set to appear immediately, and React's `StrictMode` runs every effect's setup, cleanup, and setup again on mount in development. The client entry runs once per page load and never on the server, and hydration doesn't wait for `initAll`, so guides and surveys load without holding up the page. `amplitudeReady` resolves to `false` when the key is missing or `initAll` fails, so nothing waits forever, and a failure logs `[amplitude] initAll failed` in the console.

Session replay keeps the product analytics page's rate: every session in development, about 1% in production, which is where Amplitude recommends starting. If you added masking on the session replay page, it stays in your file and applies to sessions where a guide or survey shows. Set `sampleRate` to `0` to turn session replay off. Don't pass `engagement: { skip: true }`, which stops guides and surveys from loading at all.

## 3. Connect guide buttons to React Router

A guide's Visit link action reloads the page unless you give the SDK a router. A Run callback action does nothing unless your code has registered a function with that name. Both run through `navigate()` from `useNavigate`, which only works inside a component, so register them from one small hook:

app/lib/engagement-bridge.ts

```typescript
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { amplitudeReady } from "./amplitude-ready";
import { getEngagement } from "./engagement";

export function useEngagementBridge() {
  const navigate = useNavigate();

  useEffect(() => {
    // StrictMode runs setup, cleanup, then setup again in development.
    // Only the run that wasn't cleaned up registers.
    let cancelled = false;

    void amplitudeReady.then((ok) => {
      // Analytics is off, initAll failed, or this run was cleaned up.
      if (!ok || cancelled) return;

      const engagement = getEngagement();
      if (!engagement) {
        console.warn("window.engagement not found; guide links will reload the page.");
        return;
      }

      // Guide links set to "Use router" call this instead of reloading.
      engagement.setRouter((url) => {
        const target = new URL(url, window.location.origin);

        // Other sites load normally.
        if (target.origin !== window.location.origin) {
          window.location.assign(target.href);
          return;
        }

        void navigate(target.pathname + target.search + target.hash);
      });

      // Guide buttons with "Run callback" and the name open_new_project.
      engagement.addCallback("open_new_project", () => {
        void navigate("/projects/new");
      });

    return () => {
      cancelled = true;
    };
  }, [navigate]);
}
```

Call it from the root `App` component in `app/root.tsx`. Step 6 shows the complete `App`; the line this step adds is `useEngagementBridge();` at the top of the component.

Then, in the builder, set the URL behavior on every Visit link action to Use router, and enter `open_new_project` as the name on any Run callback action. The docs are explicit: Same tab and New tab ignore the router you set, and the callback name must match the string passed to `addCallback`.

**Why.** The docs' examples register `setRouter` from a React effect, and the root `App` is the component that mounts once per page load and stays mounted across client-side navigations, so the callback stays registered on every route. Effects only run in the browser. Waiting for `amplitudeReady` means `window.engagement` exists when `setRouter` runs. If `initAll` fails, `amplitudeReady` resolves to `false` and the bridge skips registration, and if `window.engagement` is still missing, the console warning tells you why guide links reload. `navigate()` only handles paths inside your app, so the same-origin check sends anything else through a normal page load. If you set `basename` in `react-router.config.ts`, strip it from `target.pathname` before you call `navigate()`, which adds it back.

## 4. Keep identity stable so guides show once

Amplitude remembers which guides and surveys a user has seen, completed, or dismissed as part of that user's history. The docs warn that if a user's identity changes between visits, Amplitude treats them as new and shows the experience again. `amplitude.reset()` issues a new device ID, so calling it whenever the user is `null`, which is every anonymous page load, would show an anonymous visitor the same welcome guide on every visit.

The root identity effect from the product analytics page already returns early when there's no user, so keep it as it is. Reset only from the sign-out form, and close anything still on screen first:

app/components/SignOutButton.tsx

```typescript
// Keep lines other React Router pages added here: amplitude.experiment()?.clear()
// after reset() (/build/react-router-feature-flags).
import { Form } from "react-router";
import * as amplitude from "@amplitude/unified";
import { getEngagement } from "../lib/engagement";

export function SignOutButton() {
  return (
    <Form
      method="post"
      action="/logout"
      onSubmit={() => {
        // Order: closeAll, reset, clear.
        getEngagement()?.gs.closeAll(); // close anything still on screen
        amplitude.reset(); // clears the user ID and issues a new device ID
        amplitude.experiment()?.clear(); // from /build/react-router-feature-flags; harmless without flags
      }}
    >
      <button type="submit">Sign out</button>
    </Form>
  );
}
```

Point `action` at your own sign-out route. This page adds `closeAll()` before `reset()`. The `experiment()?.clear()` line belongs to the feature flags page; it's in the block so the file reads the same on both pages, and it does nothing if you don't use flags. If you don't have a sign-out route yet, skip this file until you do.

**Why.** Guides and surveys read the same user as analytics, so targeting sees the user ID and plan that the root effect sets. The default bucketing unit for rollouts is User ID, so set the user ID before you rely on a percentage rollout.

## 5. Give pins a target that survives a rebuild

Pins, tooltips, and card embeds attach to an element by CSS selector, and the docs recommend data attributes added specifically for guide targeting. A selector the visual picker builds from generated class names, such as CSS Modules hashes, can change when the stylesheet changes, and then the pin stops rendering. Tag the elements you plan to point at in your existing home route, rather than replacing the file:

app/routes/home.tsx (add to your existing home route; keep its meta export)

```typescript
// Keep lines other React Router pages added here: data-exp attributes and
// suppressHydrationWarning (/build/react-router-web-experiment).
// Add data-guide to the elements a guide points at, for example:
<Link to="/projects/new" data-guide="new-project-button">New project</Link>
```

If your home route doesn't import `Link` yet, add `import { Link } from "react-router";`. If the link already carries `data-exp="home-cta"` from the Web Experiment page, add `data-guide` next to it; one element can carry both. `<Link>` passes `data-guide` through to the `<a>` it renders. On your own components, a `data-` prop only reaches the DOM if the component spreads it onto an element.

In the guide builder, add a Pin step and enter `[data-guide="new-project-button"]` as its CSS selector instead of the generated one.

**Why.** The docs say that if the target element isn't in the DOM when the step renders, the pin doesn't display, and the SDK doesn't move it to another position. In a React Router app, anything inside `<Suspense>` and `<Await>` for deferred loader data, or behind client state, can render after the guide triggers. For those, set the trigger to When element appears with the same selector, so the guide fires when the element renders rather than when the page loads. On a later step in a tour, turn on Skip if target not found, which moves on if the target doesn't appear within 250 ms.

## 6. Open a survey from your own button

When a survey should open because someone clicked something, the docs recommend setting its trigger to None in the builder and calling `show` from the click handler.

app/components/FeedbackButton.tsx

```typescript
import { amplitudeReady } from "../lib/amplitude-ready";
import { getEngagement } from "../lib/engagement";

export function FeedbackButton() {
  return (
    <button
      type="button"
      onClick={() => {
        // Waits for initAll, so an early click opens the survey once it's ready.
        void amplitudeReady.then((ok) => {
          if (ok) getEngagement()?.gs.show("product-feedback");
        });
      }}
    >
      Give feedback
    </button>
  );
}
```

Render it in the root `App` component, next to the identity effect from the product analytics page and the bridge from step 3. Add the two imports below to the ones already in `app/root.tsx` and replace the `App` export. The template's imports, the product analytics page's imports (including `getUser`), the root `loader`, and your `links`, `Layout`, and `ErrorBoundary` exports stay as they are:

app/root.tsx (App export; keep the template's imports, links, Layout and ErrorBoundary, and the root loader)

```typescript
// Keep lines other React Router pages added here: the root loader and its getUser
// import (/build/react-router-product-analytics); the refetchBrowserFlags import and
// calls (/build/react-router-feature-flags); the experiment <script> in Layout's head
// (/build/react-router-web-experiment).

// Add these imports:
import { useEngagementBridge } from "./lib/engagement-bridge";
import { FeedbackButton } from "./components/FeedbackButton";

// Replace the App export:

export default function App({ loaderData }: Route.ComponentProps) {
  // Guide links and Run callback buttons, registered once in the browser.
  useEngagementBridge();

  const id = loaderData.user?.id;
  const plan = loaderData.user?.plan ?? "unknown";

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

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

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

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

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

  return (
    <>
      <header>
        <FeedbackButton />
      </header>
      <Outlet />
    </>
  );
}
```

`useEffect`, `useRef`, `amplitude`, `Outlet`, and the `Route` type are already imported from the product analytics page and the template. If you followed the feature flags page, keep its `refetchBrowserFlags` calls in the effect; this page only adds the bridge and the `FeedbackButton`.

Replace `product-feedback` with your survey's key from the builder. The click handler waits for `amplitudeReady`, so a click while `initAll` is still running opens the survey once it finishes, and a click with analytics off does nothing. `show` ignores targeting rules and limits but still respects page targeting, so the survey's Where settings must allow every route where the button renders. Because the button lives in the root `App`, that's every route in the app.

If you'd rather use the On event tracked trigger, the event has to fire in the browser. `Project Created`, tracked with `amplitude.track` from a click handler on the product analytics page, works. An event your `action` sends from the server with the Node.js SDK never reaches the guides and surveys SDK; call `show` from the browser after that request resolves instead, for example in a `useEffect` that watches `actionData` or `fetcher.data`.

**MCP.** Working in Cursor or Claude Code? [Amplitude's MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) includes `list_guides_surveys` and `get_guide_or_survey`, which list your project's guides and surveys and return each one's page targeting and trigger configuration, so your agent can check that the key it wires into `show` exists and is allowed on that route.

## Verify it worked

Run `npm run dev` and sign in as a test user. In Amplitude, create a guide with a Pin step on `[data-guide="new-project-button"]`, set to Open by default, and a button whose action is Run callback with `open_new_project`, and a survey with the key `product-feedback` and the trigger None. Add your test user under Test users and set both to Testing, or click Preview and enter your local URL (the dev server's default is `http://localhost:5173`). Then, in the app, open the pin, click its button, and submit the survey from Give feedback. Check in this order:

1. **Network tab.** Filter for `amplitude.com`. You should see requests to `gs.amplitude.com`, the default guides and surveys host, and the analytics SDK's POST to `api2.amplitude.com/2/httpapi` returning 200. None of them start until the client entry runs in the browser; the server render sends nothing.
2. **Ingestion Debugger.** In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads. It shows counts, not individual events.
3. **User lookup.** Go to Users > User Profiles and search your `user-` prefixed test ID. Within ten seconds to a minute the event stream shows `Guide Viewed` for the pin (a pin that isn't open by default skips `Guide Viewed` when someone clicks its target), and `Survey Viewed` and `Survey Submitted` for the survey you answered. Guides and surveys events don't count toward your event volume.
4. **Browser console.** `window.engagement` returns an object, not `undefined`. Run `window.engagement._debugStatus()`: `user.user_id` should be your `user-` prefixed ID, `apiKey` should be set, `stateInitialized` and `decideSuccessful` should be `true`, and `num_guides_surveys` should be non-zero on the home page.
5. **Amplitude Chrome extension.** The Guides & Surveys tab shows four setup checks: Installed, Initialized, Analytics Connected, and Booted. Let the page finish loading first, since the tab doesn't update live.
6. **In the app.** The pin appeared on New project, its button navigated to `/projects/new` without a document request in the Network tab, and Give feedback opened the survey.

A guide you already completed or dismissed won't show again. Open your user profile, go to the Guides or Surveys tab, and select Clear history for that experience, or click Reset User History in the preview bar.

## Troubleshooting

### window\.engagement is undefined

Guides and surveys never initialized. Confirm `app/entry.client.tsx` calls `initAll`, that `VITE_AMPLITUDE_API_KEY` is set (the dev server reads `.env` only at startup, so restart it), and that nothing passes `engagement: { skip: true }`.

### "window is not defined" during server rendering

Something reads `window.engagement` outside the browser, such as a call in a component body or a `loader`. Use `getEngagement()`, which returns `undefined` on the server, and call it from `useEffect` or an event handler.

### A guide set to Immediately shows twice, or not at all

Something calls `engagement.boot()` or `engagement.init()` on top of `initAll`, often in a `useEffect` that runs every time a component mounts, or twice under `StrictMode`. Delete those calls.

### Anonymous visitors see the same welcome guide on every visit

`amplitude.reset()` runs while the user is signed out and issues a new device ID, so every visit looks like a new user. Keep the early return in the root effect and call `reset()` only in the sign-out form's `onSubmit`.

### The pin never appears, but the survey button works

The target element isn't in the DOM when the step renders, or the selector no longer matches. Check `document.querySelector('[data-guide="new-project-button"]')` in the console, and for late-rendered elements use the When element appears trigger.

### Guide links reload the whole page

The link's URL behavior isn't Use router, `useEngagementBridge()` isn't called in the root `App`, or the console shows `window.engagement not found`. Run `Object.keys(window.engagement._.callbacks)` in the console; `engagement-router` should be in the list once `setRouter` has run.

### A Run callback button does nothing

The name in the builder doesn't match the `addCallback` string. Run `Object.keys(window.engagement._.callbacks)` in the console; `open_new_project` should be in the list, next to internal entries like `engagement-router`.

### A guide shows on some pages but not on the same page with a trailing slash

Where rules match the exact URL, including trailing slashes. React Router serves both `/projects` and `/projects/` unless you redirect one to the other, so write the rule to match the URLs your links produce, or use URL/screen starts with.

### Guide links reload and the console shows \[amplitude] initAll failed

`initAll` rejected, so `amplitudeReady` resolved to `false` and the bridge never registered. A blocked guides and surveys or session replay script, for example from an ad blocker, can cause this. Allow the Amplitude hosts, or test in a browser profile without extensions.

### Preview never opens on your page

Your app sends `Cross-Origin-Opener-Policy: same-origin`, which blocks the messages Preview mode depends on. Set it to `same-origin-allow-popups`, as in step 1.

## Full code reference

Every file this tutorial adds or changes, in one block: the version to hand to an agent. 8 files, 219 lines. Each shared file starts with a comment naming the lines other React Router pages add there; merge into your file and keep those lines. In `app/root.tsx`, add the two imports and replace only the `App` export; in `app/routes/home.tsx`, add the attribute to your existing link.

8 files, 219 lines

```typescript
// -- 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/lib/engagement.ts --
type Engagement = {
  setRouter(fn: (url: string) => void): void;
  addCallback(name: string, fn: () => void): void;
  gs: {
    show(key: string, stepIndex?: number): void;
    closeAll(): void;
  };
  _debugStatus?: () => unknown;
};

export function getEngagement(): Engagement | undefined {
  if (typeof window === "undefined") return undefined;
  return (window as unknown as { engagement?: Engagement }).engagement;
}

// -- app/entry.client.tsx --
// Keep lines other React Router pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/react-router-web-analytics); debugMode and privacyConfig
// (/build/react-router-session-replay); the amplitude-cookies import and handoff
// lines, the deviceId spread, and the experiment block
// (/build/react-router-feature-flags); the webExperiment.plugin() registration after
// initAll (/build/react-router-web-experiment).
// This page adds the setAmplitudeReady import, the setAmplitudeReady(...) wrapper around initAll, and setAmplitudeReady(null).
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import * as amplitude from "@amplitude/unified";
import { setAmplitudeReady } from "./lib/amplitude-ready";

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

if (apiKey) {
  // initAll starts analytics, session replay, Feature Experiment, and
  // guides and surveys. Guides and surveys boot with the analytics user.
  setAmplitudeReady(
    amplitude.initAll(apiKey, {
      analytics: {
        autocapture: {
          attribution: true,
          pageViews: true,
          sessions: true,
          formInteractions: true,
          fileDownloads: true,
          elementInteractions: true,
        },
      sessionReplay: {
        // Every session while you build; about 1% in production.
        sampleRate: import.meta.env.DEV ? 1 : 0.01,
      },
    }),
  );
} else {
  console.warn("VITE_AMPLITUDE_API_KEY is not set; analytics is off.");
  setAmplitudeReady(null);
}

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

// -- app/lib/engagement-bridge.ts --
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { amplitudeReady } from "./amplitude-ready";
import { getEngagement } from "./engagement";

export function useEngagementBridge() {
  const navigate = useNavigate();

  useEffect(() => {
    let cancelled = false;

    void amplitudeReady.then((ok) => {
      if (!ok || cancelled) return;

      const engagement = getEngagement();
      if (!engagement) {
        console.warn("window.engagement not found; guide links will reload the page.");
        return;
      }

      engagement.setRouter((url) => {
        const target = new URL(url, window.location.origin);
        if (target.origin !== window.location.origin) {
          window.location.assign(target.href);
          return;
        }
        void navigate(target.pathname + target.search + target.hash);
      });

      engagement.addCallback("open_new_project", () => {
        void navigate("/projects/new");
      });

    return () => {
      cancelled = true;
    };
  }, [navigate]);
}

// -- app/components/SignOutButton.tsx --
// Keep lines other React Router pages added here: amplitude.experiment()?.clear()
// after reset() (/build/react-router-feature-flags).
import { Form } from "react-router";
import * as amplitude from "@amplitude/unified";
import { getEngagement } from "../lib/engagement";

export function SignOutButton() {
  return (
    <Form
      method="post"
      action="/logout"
      onSubmit={() => {
        // Order: closeAll, reset, clear.
        getEngagement()?.gs.closeAll(); // close anything still on screen
        amplitude.reset(); // clears the user ID and issues a new device ID
        amplitude.experiment()?.clear(); // from /build/react-router-feature-flags; harmless without flags
      }}
    >
      <button type="submit">Sign out</button>
    </Form>
  );
}

// -- app/routes/home.tsx (add to your existing home route; keep its meta export) --
// Keep lines other React Router pages added here: data-exp attributes and
// suppressHydrationWarning (/build/react-router-web-experiment).
// Add data-guide to the elements a guide points at, for example:
<Link to="/projects/new" data-guide="new-project-button">New project</Link>

// -- app/components/FeedbackButton.tsx --
import { amplitudeReady } from "../lib/amplitude-ready";
import { getEngagement } from "../lib/engagement";

export function FeedbackButton() {
  return (
    <button
      type="button"
      onClick={() => {
        // Waits for initAll, so an early click opens the survey once it's ready.
        void amplitudeReady.then((ok) => {
          if (ok) getEngagement()?.gs.show("product-feedback");
        });
      }}
    >
      Give feedback
    </button>
  );
}

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

// Add these imports:
import { useEngagementBridge } from "./lib/engagement-bridge";
import { FeedbackButton } from "./components/FeedbackButton";

// Replace the App export:

export default function App({ loaderData }: Route.ComponentProps) {
  useEngagementBridge();

  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) return;
    amplitude.setUserId(`user-${id}`);
    const identify = new amplitude.Identify();
    identify.set("plan", plan);
    amplitude.identify(identify);
  }, [id, plan]);

  return (
    <>
      <header>
        <FeedbackButton />
      </header>
      <Outlet />
    </>
  );
}

// Unchanged from earlier pages; keep your versions: .env, app/auth.server.ts, app/routes.ts, app/routes/new-project.tsx, the root loader in app/root.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.

Using Cursor or Claude Code? Paste this prompt

```text
Complete https://amplitude.com/build/react-router-product-analytics first, or
confirm this React Router framework mode app already has its setup;
create its app/auth.server.ts stub and app/routes/new-project.tsx route
at projects/new if they're missing. Then add Amplitude guides and
surveys by following https://amplitude.com/build/react-router-guides-surveys
exactly. Merge into existing shared files and never delete lines another
Amplitude Build page added; each file's first comment on that page names
them. Use @amplitude/unified's initAll, called once in
app/entry.client.tsx before hydrateRoot; never call engagement.init() or
engagement.boot(), and never initialize in a useEffect. Wrap initAll in
setAmplitudeReady from app/lib/amplitude-ready.ts and call
setAmplitudeReady(null) when VITE_AMPLITUDE_API_KEY is empty; change
nothing else in that file. Don't pass engagement: { skip: true }. Add
app/lib/engagement.ts with a typed getEngagement() that returns undefined
on the server, and app/lib/engagement-bridge.ts with a
useEngagementBridge hook that waits for amplitudeReady, ignores
StrictMode's cleaned-up run, and registers setRouter (same-origin URLs
through navigate(), others through window.location.assign) and an
open_new_project callback. In app/root.tsx, add the two imports and call
the hook at the top of the root App component; keep the getUser import,
the loader, Layout and any refetchBrowserFlags calls. Keep the identity
effect's early return when the user is null. In the sign-out form's
onSubmit, call getEngagement()?.gs.closeAll(), then amplitude.reset(),
then amplitude.experiment()?.clear(). Add data-guide="new-project-button"
to the New project link in app/routes/home.tsx without replacing the
file, and render a FeedbackButton in the root App that waits for
amplitudeReady and calls gs.show('product-feedback'). If the app sends a
CSP or COOP header, add https://*.amplitude.com to script-src,
connect-src, img-src, media-src, and style-src, and set COOP to
same-origin-allow-popups; if it already uses a nonce, pass it as
engagement: { nonce } in initAll. Don't hardcode the key and don't
invent survey or callback names. Report every file you changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay), on at the sample rate from step 2, lets you watch what users did right after they submitted `product-feedback`.
- [Heatmaps](https://amplitude.com/heatmaps) are built from session replays, so they show whether people click `new-project-button` more after the pin goes live.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in the same package, can gate the feature a guide announces, so the flag and the guide reach the same users.
- [Product analytics](https://amplitude.com/amplitude-analytics) charts `Guide Viewed` next to `Project Created`, so you can see whether the pin moves project creation.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add feature flags and experiments to a React Router appGate the feature a guide announces behind a flag, evaluated in a loader for the first paint.](/build/react-router-feature-flags)[Web frameworksHow to add session replay to a React Router appRecord the sessions around a guide or survey, and mask what they shouldn't show.](/build/react-router-session-replay)[Web frameworksHow to add heatmaps to a React Router appSee where people click before and after a pin goes live.](/build/react-router-heatmaps)

Reference docs

[Guides and Surveys Web SDK →](https://amplitude.com/docs/sdks/guides-and-surveys/sdk)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Get started with guides and surveys →](https://amplitude.com/docs/guides-and-surveys/get-started)[Troubleshoot guides and surveys →](https://amplitude.com/docs/guides-and-surveys/troubleshooting/guide-or-survey-not-showing)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

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

For agents

[View raw .md](/build/react-router-guides-surveys.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
