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 insrc/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
useAuthhook 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.
npm install @amplitude/unifiedVITE_AMPLITUDE_API_KEY=your-project-api-keyVite 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:
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-popupsCross-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.
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 };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:
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.
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:
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;
}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:
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.
export function FeedbackButton() {
return (
<button
type="button"
onClick={() => window.engagement?.gs.show('product-feedback')}
>
Give feedback
</button>
);
}Mount everything in App:
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:
- Browser console.
window.engagementreturns an object, notundefined. Runwindow.engagement._debugStatus():user.user_idshould be youruser-prefixed ID,apiKeyshould be set,stateInitializedanddecideSuccessfulshould betrue, andnum_guides_surveysshould be non-zero on the home page. - 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.
- In the app. The pin appears on New project. Its button navigates to
/projects/newwithout a full page load, and the Network tab shows no new document request. Give feedback opens the survey. - Network tab. Filter for
amplitude.com. You should see requests togs.amplitude.com, the default guides and surveys host, and the analytics SDK'sPOSTtoapi2.amplitude.com/2/httpapireturning200. - 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 showsGuide Viewedfor the pin, andSurvey ViewedandSurvey Submittedafter 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.
Guide links reload the whole page
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.
// -- .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.
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-buttonmore 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 Viewednext toProject Created, so you can see whether the pin moves project creation.
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.