How to add guides and surveys to a React app

Turn on guides and surveys in a Vite and React Router app, send guide links and buttons through your router, anchor a pin to a React component, and open a survey from your own button. About 20 minutes, all copy-paste.

  • React 18+
  • Vite
  • React Router
  • Unified SDK 1.x
  • 20 min

Chris Tam · Last updated 7 October 2026

React apps change screens without reloading the page. Guides and surveys walk users through those screens 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 in a Vite app, route guide links through React Router, anchor a pin to a component, and open a survey from a button. It builds on the React product analytics page.

What you'll build

A Vite and React Router app where guides and surveys boot once with the same user and device ID as your analytics, guide links marked Use router navigate with useNavigate, 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 Vite project with React 18 or later, TypeScript, and React Router's BrowserRouter, with Amplitude analytics already initialized in src/lib/amplitude.ts. If you don't have that yet, start with Add product analytics to a React app.
  • 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.
  • A way to know who's signed in on the client, such as the useAuth hook from the product analytics page.

No Amplitude account yet? A free account is enough for this tutorial; the docs list the standard triggers it uses on all plans. Sign up for free to get your project API key.

1. Install the SDK and turn guides and surveys on

The Quickstart recommends @amplitude/unified for browser apps installed from npm, and the Browser Unified SDK includes guides and surveys and initializes them by default. If you followed the product analytics page, it's already installed.

Terminal
npm install @amplitude/unified
.env.local
VITE_AMPLITUDE_API_KEY=your-project-api-key

Vite only exposes env vars prefixed with VITE_ to browser code. The project API key is public and its scope is limited to the minimum needed to ingest data, so shipping it to the browser is expected, but keep it out of source.

Then turn the product on in Amplitude. The Guides and Surveys Web SDK docs 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 server or host doesn't send a Content-Security-Policy or Cross-Origin-Opener-Policy header; Vite sets neither. If yours does, the docs require two changes:

Response headers (merge into the policy you already send)
Content-Security-Policy: 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
Cross-Origin-Opener-Policy: same-origin-allow-popups

Cross-Origin-Opener-Policy: same-origin blocks the messages Preview mode passes between Amplitude and your page, so previews never open; same-origin-allow-popups keeps them working. If your style-src blocks inline styles, pass your CSP nonce as engagement: { nonce } in the initAll options in step 2.

2. Initialize once, before React renders

Keep initialization in the module the product analytics page created, and import it first in src/main.tsx. The only change is a promise that resolves when initAll has finished, so components that talk to guides and surveys can wait for it.

src/lib/amplitude.ts
import * as amplitude from '@amplitude/unified';

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

let ready: Promise<void> = Promise.resolve();

if (apiKey) {
  // initAll starts analytics, session replay, Feature Experiment, and
  // guides and surveys. Guides and surveys boot with the analytics user.
  ready = Promise.resolve(
    amplitude.initAll(apiKey, {
      analytics: {
        autocapture: {
          attribution: true,
          pageViews: true,
          sessions: true,
          formInteractions: true,
          fileDownloads: true,
          elementInteractions: true,
        },
      },
      sessionReplay: {
        sampleRate: 1,
      },
    }),
  ).then(() => undefined);
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

/** Resolves once initAll, including guides and surveys, has finished. */
export function amplitudeReady() {
  return ready;
}

export { amplitude };
src/main.tsx
import './lib/amplitude';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </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 call them from a useEffect. The docs say calling boot more than once causes unexpected behavior, especially for guides and surveys set to appear immediately, and Strict Mode runs every effect twice in development. Module code runs once per page load, before createRoot, so it can't double up.

sessionReplay.sampleRate: 1 records every session, as on the product analytics page; set it to 0 if you don't want replays. Don't pass engagement: { skip: true }, which stops guides and surveys from loading at all.

window.engagement isn't in TypeScript's Window type. Declare the methods this page calls:

src/types/engagement.d.ts
export {};

declare global {
  interface Window {
    engagement?: {
      setRouter: (routerFn: (url: string) => void) => void;
      addCallback: (name: string, callback: () => void) => void;
      gs: {
        show: (key: string, stepIndex?: number) => void;
        closeAll: () => void;
      };
      _debugStatus?: () => unknown;
    };
  }
}

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 need useNavigate, which only works inside BrowserRouter, so they live in one component you mount once at the top of App.

src/analytics/EngagementBridge.tsx
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { amplitudeReady } from '../lib/amplitude';

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

  // Keep the latest navigate without re-registering on every route change.
  const navigateRef = useRef(navigate);
  useEffect(() => {
    navigateRef.current = navigate;
  }, [navigate]);

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

    amplitudeReady().then(() => {
      const engagement = window.engagement;
      if (cancelled || !engagement) return;

      // Guide links set to "Use router" call this instead of reloading.
      engagement.setRouter((url) => {
        const target = new URL(url, window.location.origin);
        if (target.origin === window.location.origin) {
          navigateRef.current(target.pathname + target.search + target.hash);
        } else {
          window.location.assign(url);
        }
      });

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

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

  return null;
}

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' React Router example calls setRouter in a useEffect; this version waits for amplitudeReady() first, so window.engagement exists when the effect runs. The same-origin check keeps links to other sites as normal navigations, since navigate only handles routes inside your app. Mount the component where it never unmounts, so the callback stays registered.

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. The product analytics page's IdentifyUser calls amplitude.reset() whenever user is null, which is every anonymous page load, and reset() issues a new device ID. A visitor who isn't signed in would see the same welcome guide on every visit.

Reset only when someone signs out:

src/analytics/IdentifyUser.tsx
import { useEffect } from 'react';
import { amplitude } from '../lib/amplitude';

type User = { id: string; plan: string } | null;

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    if (!user) return; // anonymous: keep the same device ID

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

    const identify = new amplitude.Identify();
    identify.set('plan', user.plan); // a flat string you can target on
    amplitude.identify(identify);
  }, [user?.id, user?.plan]);

  return null;
}
src/auth/signOut.ts
import { amplitude } from '../lib/amplitude';

export async function signOut() {
  // ... your sign-out logic

  window.engagement?.gs.closeAll(); // close anything still on screen
  amplitude.reset(); // clears the user ID and issues a new device ID
}

Why. Guides and surveys refresh targeting automatically when the user or user properties change, so setUserId and identify are all targeting needs. Set plan as a flat string: targeting doesn't flatten arrays of objects, so a dotted path like subscription.plan from an array isn't available. To target new users, filter on the built-in First Seen property rather than a cohort, since cohort membership updates with a delay and won't match on a user's first visit. 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 CSS Modules or CSS-in-JS class names can change on the next build, and then the pin stops rendering. Tag the elements you plan to point at:

src/pages/Home.tsx
import { Link } from 'react-router-dom';

export function Home() {
  return (
    <section data-guide="home">
      <h1>Your projects</h1>
      <Link to="/projects/new" data-guide="new-project-button">
        New project
      </Link>
    </section>
  );
}

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 app, anything behind Suspense, a lazy() route, or a data fetch 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.

src/components/FeedbackButton.tsx
export function FeedbackButton() {
  return (
    <button
      type="button"
      onClick={() => window.engagement?.gs.show('product-feedback')}
    >
      Give feedback
    </button>
  );
}

Mount everything in App:

src/App.tsx
import { Routes, Route } from 'react-router-dom';
import { EngagementBridge } from './analytics/EngagementBridge';
import { IdentifyUser } from './analytics/IdentifyUser';
import { useAuth } from './auth/useAuth';
import { FeedbackButton } from './components/FeedbackButton';
import { Home } from './pages/Home';
import { NewProject } from './pages/NewProject';

export function App() {
  const { user } = useAuth(); // your auth hook

  return (
    <>
      <EngagementBridge />
      <IdentifyUser user={user} />
      <header>
        <FeedbackButton />
      </header>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/projects/new" element={<NewProject />} />
      </Routes>
    </>
  );
}

Replace product-feedback with your survey's key from the builder. 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.

If you'd rather use the On event tracked trigger, the event has to fire in the browser. Project Created, tracked with amplitude.track on the product analytics page, works. An event your backend sends never reaches the guides and surveys SDK; call show from the client after that request resolves instead.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server 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. Set up the MCP server.

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"] 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. Then check in this order:

  1. 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.
  2. 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.
  3. In the app. The pin appears on New project. Its button navigates to /projects/new without a full page load, and the Network tab shows no new document request. Give feedback opens the survey.
  4. 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.
  5. 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.
  6. 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, and Survey Viewed and Survey Submitted after you answer. Guides and surveys events don't count toward your event volume.

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 import './lib/amplitude' is the first line of src/main.tsx, that VITE_AMPLITUDE_API_KEY is set (Vite reads .env.local only at startup, so restart the dev server), and that nothing passes engagement: { skip: true }.

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

Something calls engagement.boot() or engagement.init() on top of initAll, often in a useEffect that Strict Mode runs twice. 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. Remove the reset() branch from IdentifyUser and reset only in signOut().

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.

The link's URL behavior isn't Use router, or EngagementBridge isn't mounted inside BrowserRouter. A useNavigate() may be used only in the context of a <Router> error means the second.

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.

Preview never opens on your page

Your server 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 from this tutorial in one block, the version to hand to an agent. It assumes no CSP nonce. 9 files.

11 files, 193 lines
// -- .env.local --
VITE_AMPLITUDE_API_KEY=your-project-api-key

// -- src/lib/amplitude.ts --
import * as amplitude from '@amplitude/unified';

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

let ready: Promise<void> = Promise.resolve();

if (apiKey) {
  ready = Promise.resolve(
    amplitude.initAll(apiKey, {
      analytics: {
        autocapture: {
          attribution: true,
          pageViews: true,
          sessions: true,
          formInteractions: true,
          fileDownloads: true,
          elementInteractions: true,
        },
      },
      sessionReplay: {
        sampleRate: 1,
      },
    }),
  ).then(() => undefined);
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

export function amplitudeReady() {
  return ready;
}

export { amplitude };

// -- src/types/engagement.d.ts --
export {};

declare global {
  interface Window {
    engagement?: {
      setRouter: (routerFn: (url: string) => void) => void;
      addCallback: (name: string, callback: () => void) => void;
      gs: {
        show: (key: string, stepIndex?: number) => void;
        closeAll: () => void;
      };
      _debugStatus?: () => unknown;
    };
  }
}

// -- src/main.tsx --
import './lib/amplitude';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);

// -- src/analytics/EngagementBridge.tsx --
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { amplitudeReady } from '../lib/amplitude';

export function EngagementBridge() {
  const navigate = useNavigate();
  const navigateRef = useRef(navigate);
  useEffect(() => {
    navigateRef.current = navigate;
  }, [navigate]);

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

    amplitudeReady().then(() => {
      const engagement = window.engagement;
      if (cancelled || !engagement) return;

      engagement.setRouter((url) => {
        const target = new URL(url, window.location.origin);
        if (target.origin === window.location.origin) {
          navigateRef.current(target.pathname + target.search + target.hash);
        } else {
          window.location.assign(url);
        }
      });

      engagement.addCallback('open_new_project', () => {
        navigateRef.current('/projects/new');
      });
    });

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

  return null;
}

// -- src/analytics/IdentifyUser.tsx --
import { useEffect } from 'react';
import { amplitude } from '../lib/amplitude';

type User = { id: string; plan: string } | null;

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    if (!user) return;
    amplitude.setUserId(`user-${user.id}`);
    const identify = new amplitude.Identify();
    identify.set('plan', user.plan);
    amplitude.identify(identify);
  }, [user?.id, user?.plan]);

  return null;
}

// -- src/auth/signOut.ts --
import { amplitude } from '../lib/amplitude';

export async function signOut() {
  // ... your sign-out logic
  window.engagement?.gs.closeAll();
  amplitude.reset();
}

// -- src/pages/Home.tsx --
import { Link } from 'react-router-dom';

export function Home() {
  return (
    <section data-guide="home">
      <h1>Your projects</h1>
      <Link to="/projects/new" data-guide="new-project-button">
        New project
      </Link>
    </section>
  );
}

// -- src/components/FeedbackButton.tsx --
export function FeedbackButton() {
  return (
    <button
      type="button"
      onClick={() => window.engagement?.gs.show('product-feedback')}
    >
      Give feedback
    </button>
  );
}

// -- src/App.tsx --
import { Routes, Route } from 'react-router-dom';
import { EngagementBridge } from './analytics/EngagementBridge';
import { IdentifyUser } from './analytics/IdentifyUser';
import { useAuth } from './auth/useAuth';
import { FeedbackButton } from './components/FeedbackButton';
import { Home } from './pages/Home';
import { NewProject } from './pages/NewProject';

export function App() {
  const { user } = useAuth();

  return (
    <>
      <EngagementBridge />
      <IdentifyUser user={user} />
      <header>
        <FeedbackButton />
      </header>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/projects/new" element={<NewProject />} />
      </Routes>
    </>
  );
}

// -- src/pages/NewProject.tsx --
// Unchanged from /build/react-product-analytics.

Hand it to your agent

Prefer to have Cursor or Claude Code do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt
Add Amplitude guides and surveys to this Vite + React Router app by
following https://amplitude.com/build/react-guides-surveys exactly. Use
@amplitude/unified and call initAll once at module scope in
src/lib/amplitude.ts, imported first in src/main.tsx, and export an
amplitudeReady() promise. Never call engagement.init() or
engagement.boot(), and never initialize inside useEffect. Add an
EngagementBridge component inside BrowserRouter that waits for
amplitudeReady(), then calls window.engagement.setRouter with
useNavigate for same-origin URLs and registers callbacks with
window.engagement.addCallback. Call amplitude.reset() only in the
sign-out handler, never when the user is null. Add data-guide
attributes to elements I want guides to point at. Open surveys from
click handlers with window.engagement.gs.show(key). Read
VITE_AMPLITUDE_API_KEY from the environment and don't hardcode it.
Tell me session replay records at sampleRate 1 and how to turn it off.
Report every file you changed.

What this unlocks

  • Session replay, on at the sample rate from step 2, lets you watch what users did right after they submitted product-feedback.
  • Heatmaps are built from session replays, so they show whether people click new-project-button more after the pin goes live.
  • Feature 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 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

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

Ship the instrumentation, then read the data

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