Chris Tam · Last updated 9 October 2026
Vue Router swaps views without reloading the page. Guides and surveys walk users through those views 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 Vue Router, anchor a pin, and open a survey from a button. It builds on the Vue product analytics page.
What you'll build
A Vue, Vite, and Vue 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 router.push, 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 Vue 3 app with Vite, TypeScript, and Vue Router using
createWebHistory, with Amplitude already initialized insrc/lib/amplitude.ts. If you don't have that yet, start with How to add product analytics to a Vue 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
useAuthcomposable anduseAmplitudeIdentityfrom the product analytics page.
No Amplitude account yet? A free account is enough for this tutorial (the Free plan includes one guide and one survey, which is what Verify creates). Sign up for free to get your project API key. Some guides and surveys features, like scheduled nudges, depend on your plan; compare plans.
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 anyway.
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 doesn't send a Content-Security-Policy or Cross-Origin-Opener-Policy header; the Vite dev server 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 the app mounts
Keep initialization in the module the product analytics page created, imported first in src/main.ts. The init options match the session replay page, including its masking rules, which the file below keeps. The one addition is a promise that resolves when initAll has finished, so code that talks 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: {
// Every session while you build; about 1% in production.
sampleRate: import.meta.env.PROD ? 0.01 : 1,
debugMode: !import.meta.env.PROD,
// From /build/vue-session-replay. Keep your masking rules when you paste this file.
privacyConfig: {
maskSelector: ['[data-amp-mask]'],
blockSelector: ['[data-amp-block]'],
unmaskSelector: ['[data-amp-unmask]'],
},
},
}),
).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 };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 component's onMounted. The docs say calling boot more than once causes unexpected behavior, especially for guides and surveys set to appear immediately, and onMounted runs again every time that component mounts. A module body runs once per page load, before createApp, so it can't double up.
Session replay keeps the product analytics page's rate: every session in development, about 1% in production, which is where Amplitude recommends starting. Set sampleRate to 0 to turn session replay off. Don't pass engagement: { skip: true }, which stops guides and surveys from loading at all.
window.engagement isn't in TypeScript's Window type. Read it through a small typed accessor that lists only the methods this page calls:
// 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 {
return (window as unknown as { engagement?: Engagement }).engagement;
}The accessor doesn't augment the global Window type, so it can't conflict with any declaration the SDK packages ship. Every file below calls getEngagement() instead of window.engagement.
On Nuxt, initialize in app/plugins/amplitude.client.ts (plugins/ on Nuxt 3) as on How to add product analytics to a Nuxt app, and keep SDK calls out of the server render. Register the router bridge from step 3 in a second client-only plugin that uses the router Nuxt provides, and have the init plugin provide the initAll promise so the bridge can wait on it (prefix its file name, such as 01.amplitude.client.ts, so it runs first, or give both plugins a name and use dependsOn).
3. Connect guide buttons to Vue 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 your router instance, so register them in a small Vue plugin that you install once in src/main.ts.
import type { Plugin } from 'vue';
import type { Router } from 'vue-router';
import { amplitudeReady } from '../lib/amplitude';
import { getEngagement } from '../lib/engagement';
export function createEngagementBridge(router: Router): Plugin {
return {
install() {
amplitudeReady()
.then(() => {
const engagement = getEngagement();
if (!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) {
window.location.assign(url);
return;
}
// Drop the app's base path (Vite's `base`) so the router
// doesn't add it a second time.
const base = router.options.history.base;
let path = target.pathname;
if (base && (path === base || path.startsWith(`${base}/`))) {
path = path.slice(base.length) || '/';
}
router.push(path + target.search + target.hash);
});
// Guide buttons with "Run callback" and the name open_new_project.
engagement.addCallback('open_new_project', () => {
router.push('/projects/new');
});
})
.catch((err) => console.warn('Guides and surveys bridge not registered', err));
},
};
}import './lib/amplitude';
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createEngagementBridge } from './plugins/engagement';
createApp(App).use(router).use(createEngagementBridge(router)).mount('#app');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 component (a React effect, an Angular root component). A Vue plugin gets the router instance directly, runs once when the app starts, and never unmounts, so the callback stays registered on every route. Waiting for amplitudeReady() means window.engagement exists when setRouter runs, and if initAll fails, the .catch logs a warning instead of leaving an unhandled promise rejection. The same-origin check keeps links to other sites as normal navigations, since router.push only handles routes inside your app. The base path check matters when Vite's base is set, for example an app served from /app/: the guide sends the full path, and router.push would otherwise prepend /app again.
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 useAmplitudeIdentity composable 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 handler, and close anything still on screen first:
import * as amplitude from '@amplitude/unified';
import { getEngagement } from '../lib/engagement';
export async function signOut() {
// ... your sign-out logic
getEngagement()?.gs.closeAll(); // close anything still on screen
amplitude.reset(); // clears the user ID and issues a new device ID
}Why. Guides and surveys read the same user as analytics, so targeting sees the user ID and plan that the composable 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, or from the data-v- attributes Vue adds for scoped styles, can change when the component changes, and then the pin stops rendering. Tag the elements you plan to point at:
<template>
<section data-guide="home">
<h1>Your projects</h1>
<RouterLink to="/projects/new" data-guide="new-project-button">
New project
</RouterLink>
</section>
</template>RouterLink passes attributes it doesn't recognize through to the <a> it renders, so the selector matches the link itself. 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 Vue app, anything behind a lazy route component, defineAsyncComponent, <Suspense>, or a v-if that waits for data 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.
<script setup lang="ts">
import { getEngagement } from '../lib/engagement';
function openFeedback() {
getEngagement()?.gs.show('product-feedback');
}
</script>
<template>
<button type="button" @click="openFeedback">Give feedback</button>
</template>Render it in App.vue, next to the identity composable from the product analytics page:
<script setup lang="ts">
import { useAuth } from './auth/useAuth'; // your auth composable
import { useAmplitudeIdentity } from './composables/useAmplitudeIdentity';
import FeedbackButton from './components/FeedbackButton.vue';
const { user } = useAuth();
useAmplitudeIdentity(user);
</script>
<template>
<header>
<FeedbackButton />
</header>
<RouterView />
</template>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"], 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. Then, in the app, open the pin, click its button, and submit the survey from Give feedback. Check in this order:
- 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 (a pin that isn't open by default skipsGuide Viewedwhen someone clicks its target), andSurvey ViewedandSurvey Submittedfor the survey you answered. Guides and surveys events don't count toward your event volume. - 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 appeared on New project, its button navigated to
/projects/newwithout 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 import './lib/amplitude' is the first line of src/main.ts, 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, or not at all
Something calls engagement.boot() or engagement.init() on top of initAll, often in a component's onMounted that runs on every mount. 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 useAmplitudeIdentity and call 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 createEngagementBridge(router) isn't installed in src/main.ts. Run Object.keys(window.engagement._.callbacks) in the console; engagement-router should be in the list once setRouter has run.
Guide links land on a 404 with the base path twice, like /app/app/projects/new
Your app sets Vite's base, and the bridge is pushing the full pathname. Keep the base path check from step 3, and create the router with createWebHistory(import.meta.env.BASE_URL) so router.options.history.base matches.
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, plus a note on the router you already have. src/composables/useAmplitudeIdentity.ts stays as it is on the Vue product analytics page.
// -- .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: import.meta.env.PROD ? 0.01 : 1,
debugMode: !import.meta.env.PROD,
// From /build/vue-session-replay. Keep your masking rules when you paste this file.
privacyConfig: {
maskSelector: ['[data-amp-mask]'],
blockSelector: ['[data-amp-block]'],
unmaskSelector: ['[data-amp-unmask]'],
},
},
}),
).then(() => undefined);
} else {
console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}
export function amplitudeReady() {
return ready;
}
export { amplitude };
// -- src/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 {
return (window as unknown as { engagement?: Engagement }).engagement;
}
// -- src/plugins/engagement.ts --
import type { Plugin } from 'vue';
import type { Router } from 'vue-router';
import { amplitudeReady } from '../lib/amplitude';
import { getEngagement } from '../lib/engagement';
export function createEngagementBridge(router: Router): Plugin {
return {
install() {
amplitudeReady()
.then(() => {
const engagement = getEngagement();
if (!engagement) return;
engagement.setRouter((url) => {
const target = new URL(url, window.location.origin);
if (target.origin !== window.location.origin) {
window.location.assign(url);
return;
}
const base = router.options.history.base;
let path = target.pathname;
if (base && (path === base || path.startsWith(`${base}/`))) {
path = path.slice(base.length) || '/';
}
router.push(path + target.search + target.hash);
});
engagement.addCallback('open_new_project', () => {
router.push('/projects/new');
});
})
.catch((err) => console.warn('Guides and surveys bridge not registered', err));
},
};
}
// -- src/main.ts --
import './lib/amplitude';
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createEngagementBridge } from './plugins/engagement';
createApp(App).use(router).use(createEngagementBridge(router)).mount('#app');
// -- src/auth/signOut.ts --
import * as amplitude from '@amplitude/unified';
import { getEngagement } from '../lib/engagement';
export async function signOut() {
// ... your sign-out logic
getEngagement()?.gs.closeAll();
amplitude.reset();
}
// -- src/views/HomeView.vue --
// Example: add the data-guide attributes to your own markup.
<template>
<section data-guide="home">
<h1>Your projects</h1>
<RouterLink to="/projects/new" data-guide="new-project-button">
New project
</RouterLink>
</section>
</template>
// -- src/components/FeedbackButton.vue --
<script setup lang="ts">
import { getEngagement } from '../lib/engagement';
function openFeedback() {
getEngagement()?.gs.show('product-feedback');
}
</script>
<template>
<button type="button" @click="openFeedback">Give feedback</button>
</template>
// -- src/App.vue --
<script setup lang="ts">
import { useAuth } from './auth/useAuth';
import { useAmplitudeIdentity } from './composables/useAmplitudeIdentity';
import FeedbackButton from './components/FeedbackButton.vue';
const { user } = useAuth();
useAmplitudeIdentity(user);
</script>
<template>
<header>
<FeedbackButton />
</header>
<RouterView />
</template>
// -- src/router/index.ts --
// Your existing router. It must use createWebHistory(import.meta.env.BASE_URL)
// and have a /projects/new route.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.
Add Amplitude guides and surveys to this Vue, Vite, and Vue Router app by
following https://amplitude.com/build/vue-guides-surveys exactly. Use
@amplitude/unified and call initAll once at module scope in
src/lib/amplitude.ts, imported first in src/main.ts, keeping any
sessionReplay.privacyConfig already there, and export an amplitudeReady()
promise. Never call engagement.init() or engagement.boot(), and never
initialize in onMounted. Read window.engagement only through a typed
getEngagement() helper in src/lib/engagement.ts; don't augment the global
Window type. Add a Vue plugin, createEngagementBridge(router), installed
in src/main.ts, that waits for amplitudeReady(), catches a rejection with
a console warning, then calls setRouter with router.push for same-origin
URLs (stripping the router's base path) and registers callbacks with
addCallback. Don't replace my router. Call amplitude.reset() only in the
sign-out handler, never when the user is null. Don't replace my views;
add data-guide attributes to existing elements I want guides to point at.
Open surveys from click handlers
with getEngagement()?.gs.show(key). Read VITE_AMPLITUDE_API_KEY from the
environment and don't hardcode it. Tell me session replay records every
session in development and about 1% in production, 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.