Chris Tam · Last updated 9 October 2026
A Vue app with Vue Router loads one real page, then swaps every view after that in the browser. Web analytics tells you where those visitors come from, which campaigns bring them, and which pages keep them.
In this tutorial, you'll configure Amplitude for traffic data, keep campaign parameters through Vue Router redirects, and set up the channel and campaign views. It builds on the Vue product analytics page.
What you'll build
A Vue app that sends [Amplitude] Page Viewed on first load and on every route change, starts and ends sessions automatically, and stores utm_source, utm_medium, utm_campaign, referrer, and click IDs as both user properties and event properties, including on visits that land on a URL a navigation guard or redirect rewrites. By the end you'll see a test campaign in your event stream and a Marketing Analytics view configured to read it.
Prerequisites
- A Vue 3 app built with Vite, TypeScript, and Vue Router 4 using
createWebHistory, for example one created withnpm create vue@latest. Step 3 coverscreateWebHashHistory. - Using Nuxt? Read the note in step 2, and follow How to add product analytics to a Nuxt app for the plugin setup.
- An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
- An Admin or Manager role in the Amplitude project for step 5. The Member role can't edit Marketing Analytics settings.
No Amplitude account yet? A free account covers the tracking on this page. Sign up for free to get your project API key. Marketing Analytics views and channel classification depend on your plan; compare plans.
1. Install the SDK
The Quickstart recommends @amplitude/unified for browser apps installed from npm, and the Vue product analytics page uses it, so this setup changes the same src/lib/amplitude.ts. If you already followed that page, skip to step 2.
npm install @amplitude/unifiedVite 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.
VITE_AMPLITUDE_API_KEY=your-project-api-key2. Configure autocapture for traffic data
Attribution, page views, sessions, and page URL enrichment are all on by default. Name them anyway, so nobody turns one off later without knowing what it feeds, and change one: trackHistoryChanges. This is the complete file, with the base page's clicks and session replay settings kept.
import * as amplitude from '@amplitude/unified';
const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;
if (apiKey) {
amplitude.initAll(apiKey, {
analytics: {
autocapture: {
// UTM, referrer, and click ID capture. Feeds Traffic by Channel and Campaign.
attribution: true,
// [Amplitude] Page Viewed on load and on every path change.
// 'pathOnly' ignores query-string-only changes such as ?sort=price.
// Using createWebHashHistory? Read step 3 before keeping this line.
pageViews: { trackHistoryChanges: 'pathOnly' },
// [Amplitude] Start Session and End Session. Feeds bounce rate and session length.
sessions: true,
// Adds current and previous page URL properties.
pageUrlEnrichment: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
},
sessionReplay: {
// Keep debugMode and privacyConfig here if you added them on /build/vue-session-replay.
// Every session while you build; about 1% in production.
sampleRate: import.meta.env.PROD ? 0.01 : 1,
},
});
} else {
console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}
export { amplitude };Import it first in your entry file, before createApp:
import './lib/amplitude';
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
createApp(App).use(router).mount('#app');Why. Run init at module scope so it starts before Vue Router resolves the first route. The SDK reads campaign data from the URL shortly after init, so step 4 keeps the parameters in the URL through any redirect that happens first. An onMounted hook runs after the first navigation, by which point a guard or redirect may already have replaced the URL.
@amplitude/unified turns on session replay when sessionReplay.sampleRate is above 0. This config records every session in development and about 1% in production, which is where Amplitude recommends starting; raise it over a few days. Set it to 0 to turn session replay off. Web analytics doesn't need it; it's here because the base page set it.
From Browser SDK 2.45.0, the SDK stores campaign parameters two ways by default: as user properties (including first-touch initial_utm_source and friends, set once) and as event properties on every event. If the Network tab check below shows no utm_ values in event_properties, update @amplitude/unified to a release that bundles Browser SDK 2.45.0 or later. For referrer exclusions and the other attribution options, see Track marketing attribution.
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. Use the same autocapture block as above, with Nuxt's dev check for the sample rate. Route middleware that returns navigateTo('/login') drops the query string the same way the guards in step 4 do, so pass { path: '/login', query: to.query, hash: to.hash } instead.
import * as amplitude from '@amplitude/unified';
export default defineNuxtPlugin(() => {
const apiKey = useRuntimeConfig().public.amplitudeApiKey;
if (!apiKey) {
console.warn('NUXT_PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.');
return;
}
amplitude.initAll(apiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: { trackHistoryChanges: 'pathOnly' },
sessions: true,
pageUrlEnrichment: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
},
sessionReplay: {
// Keep debugMode and privacyConfig here if you added them on /build/vue-session-replay.
sampleRate: import.meta.dev ? 1 : 0.01,
},
});
});3. Match page views to your history mode
There's no page view code in this app, and there shouldn't be. With pageViews on, the SDK sends [Amplitude] Page Viewed on init and again when the URL changes inside the single-page app, which covers <RouterLink> clicks and router.push(). Each event carries [Amplitude] Page Path, [Amplitude] Page Title, [Amplitude] Page URL, [Amplitude] Page Location, and [Amplitude] Page Counter.
trackHistoryChanges: 'pathOnly' sends a page view when the path changes, such as /pricing to /docs, and skips changes to only the query string or hash. Use the default, 'all', if your query strings mark different pages, such as /blog?page=2. With 'all', a product list that calls router.replace({ query: { sort: 'price' } }) on every filter click records a page view per click, which inflates page views per session and skews entry and exit pages.
createWebHashHistory keeps the route in the hash (/#/pricing). [Amplitude] Page Path excludes the hash, so it reads / on every screen, and with 'pathOnly' route changes never send a page view. If you use hash history, set pageViews: true, which tracks hash changes, and break page reports down by [Amplitude] Page Location (the full URL) rather than [Amplitude] Page Path. Hash mode also puts Vue Router's own query after the #, so campaign links need their UTM parameters in the real query string, before it: /?utm_source=newsletter#/pricing. createWebHistory avoids all three problems.
Don't add a router.afterEach hook that calls track('Page Viewed'). It produces a second, differently named page view per navigation, and Marketing Analytics only counts the one it's configured to read.
4. Keep UTM parameters through Vue Router redirects
The SDK can only record campaign data that's in the URL. A navigation guard or router.replace() goes exactly where you point it, and a plain path has no query string. A visitor arrives at /account?utm_source=newsletter, a navigation guard returns '/login', and every event on /login reads an empty campaign. Amplitude's docs call this out for single-page apps: UTM parameters can drop during SPA redirects.
Route records are the safe case. A string redirect such as { path: '/plans', redirect: '/pricing' } carries the incoming query and hash across on its own. Three patterns drop them:
- A navigation guard that returns a path string, such as
return '/login'. - A redirect function, or a redirect string with its own
?, that sets a query. The new query replaces the incoming one. - A programmatic
router.replace('/welcome')after sign-in.
Carry query and hash across with one small helper:
import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router';
type Source = Pick<RouteLocationNormalized, 'query' | 'hash'>;
// Returning '/login' on its own drops ?utm_source=...
export function keepQuery(source: Source, path: string): RouteLocationRaw {
return { path, query: source.query, hash: source.hash };
}Use it in every guard, and merge the incoming query in every redirect that sets one:
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import PricingView from '../views/PricingView.vue';
import LoginView from '../views/LoginView.vue';
import AccountView from '../views/AccountView.vue';
import { isSignedIn } from '../auth/isSignedIn'; // your auth check
import { keepQuery } from './keepQuery';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: HomeView },
{ path: '/pricing', component: PricingView },
{ path: '/login', component: LoginView },
{ path: '/account', component: AccountView, meta: { requiresAuth: true } },
// Old campaign URL. A string redirect keeps ?utm_... on its own.
{ path: '/plans', redirect: '/pricing' },
// Sets its own query, so spread the incoming one or lose ?utm_...
{
path: '/offer/:code',
redirect: (to) => ({
path: '/pricing',
query: { ...to.query, offer: to.params.code },
hash: to.hash,
}),
},
],
});
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isSignedIn()) {
return keepQuery(to, '/login');
}
});
export default router;After sign-in, send people on with router.replace(keepQuery(route, '/welcome')), where route comes from useRoute(), rather than router.replace('/welcome').
Why. Event property tracking re-reads campaign parameters on page load and on every SPA navigation, including the replaceState a redirect performs. Whatever is in the URL after the redirect is what gets attached to the events that follow.
Check each guard and redirect in your app with a ?utm_source=test URL before you trust your channel numbers.
5. Set up the Marketing Analytics views
In Amplitude, web analytics lives under Marketing Analytics in the navigation bar. It uses Browser SDK events by default, so this step is configuration, not code.
- Create the channel classifier. Go to Data > Properties > Channels. Opening that page creates
[Amplitude] Default Channels, which maps UTM parameters and referrer data into GA4-aligned channels such as Paid Search and Organic Social. If your org predates it, click Build with the new logic when prompted. - Confirm the page view event. Open Marketing Analytics, click Customize, and select Page View and Filter. Choose
[Amplitude] Page Viewedas the event. Set a domain filter if the project also receives events from another site. - Define a conversion funnel. Under Conversion Funnel, choose the events that make up your funnel. If you followed the Vue product analytics page,
[Amplitude] Page Viewedfollowed byProject Createdworks as a first funnel. - Read the views. Traffic by Channel, Traffic by Campaign, Page Engagement, and Conversion each show a time series and a table. Page Engagement covers visitors, page views, bounce rate, page views per session, and entry and exit rate per page. Ad Performance needs an ad network source such as Google Ads connected first.
For every setting, see Out-of-the-box Marketing Analytics.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent look up the events already in your project before it writes track calls, so a conversion event matches a name you already use instead of inventing projectCreated next to Project Created. Set up the MCP server.
Verify it worked
Run npm run dev, open http://localhost:5173/offer/spring?utm_source=build-test&utm_medium=email&utm_campaign=vue-web-analytics in a fresh private window, click through two routes, then check in this order:
- Network tab. First check the address bar: after the redirect you should be on
/pricingwith all threeutm_parameters andoffer=springin the URL, and/account?utm_source=build-testopened while signed out should land on/loginwithutm_sourcestill there. If either loses its parameters, the fix is step 4, and nothing downstream will work until it is. Then filter the Network tab forapi2.amplitude.com. You should see aPOSTreturning200. The SDK compresses request bodies of 2 KB or more, so read the events in the Instrumentation Explorer Chrome extension instead: the[Amplitude] Page Viewedevent for/pricingshould carryutm_source: build-testinevent_properties, and an identify event should setutm_sourceandinitial_utm_sourceas user properties. - 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 device ID (the Instrumentation Explorer Chrome extension shows it as device_id). Within ten seconds to a minute, the event stream shows
[Amplitude] Start Session, then one[Amplitude] Page Viewedper route, and the profile showsinitial_utm_source: build-test. - Marketing Analytics. Open Traffic by Campaign for today.
vue-web-analyticsshould appear as a campaign. It can take a few minutes to appear.
To watch payloads in the console, add logLevel: amplitude.Types.LogLevel.Debug to the analytics block in development.
Troubleshooting
Two page view events per navigation
A router.afterEach hook or a page view composable is running alongside autocapture. Delete it. If both events are [Amplitude] Page Viewed, check that initAll is only called from src/lib/amplitude.ts.
A page view every time a filter or sort changes
The page writes state to the query string with router.replace({ query }) and trackHistoryChanges is 'all'. Set pageViews: { trackHistoryChanges: 'pathOnly' } as in step 2.
Only one page view per visit, no matter how many routes
The app uses createWebHashHistory with trackHistoryChanges: 'pathOnly'. Every route shares the path /, so no path change ever happens. Set pageViews: true, or move to createWebHistory.
Campaign traffic shows up as Direct
The UTM parameters were gone by the time the SDK read them. Load a ?utm_source=test URL and watch the address bar: a guard that returns a path string, a redirect that sets its own query, or a router.replace() that doesn't pass query is the usual cause. The Channels docs also note that inconsistent set() and unset() calls on UTM user properties can classify traffic as Other.
Every page in Page Engagement has the same title
Your app sets <title> once in index.html and never changes it, so every [Amplitude] Page Title is identical. Break the view down by [Amplitude] Page Path under Customize > Breakdown.
Page views stopped even though the code enables them
Remote configuration overrides local autocapture settings feature by feature, and Browser SDK 2.16.1 and later fetch it by default. Check Data > Settings > Autocapture and make sure page views and sessions aren't turned off there.
No requests in the Network tab
The key is undefined, so the warning from step 2 is in the console. The variable must start with VITE_, and Vite only reads .env.local at startup, so restart the dev server. In a production build the value is inlined at build time; set it in your build environment and rebuild.
Requests fail with ERR_BLOCKED_BY_CLIENT
An ad blocker is dropping the call. Route events through a proxy on your own domain and set serverUrl in the analytics block to point at it. Point remoteConfig.serverUrl at it too, so remote configuration requests aren't blocked.
Full code reference
Every file from this tutorial in one block, the version to hand to an agent. 5 files, plus your existing views.
// -- .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;
if (apiKey) {
amplitude.initAll(apiKey, {
analytics: {
autocapture: {
attribution: true,
pageViews: { trackHistoryChanges: 'pathOnly' },
sessions: true,
pageUrlEnrichment: true,
formInteractions: true,
fileDownloads: true,
elementInteractions: true,
},
},
sessionReplay: {
// Keep debugMode and privacyConfig here if you added them on /build/vue-session-replay.
sampleRate: import.meta.env.PROD ? 0.01 : 1,
},
});
} else {
console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}
export { amplitude };
// -- src/main.ts --
import './lib/amplitude';
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
createApp(App).use(router).mount('#app');
// -- src/router/keepQuery.ts --
import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router';
type Source = Pick<RouteLocationNormalized, 'query' | 'hash'>;
export function keepQuery(source: Source, path: string): RouteLocationRaw {
return { path, query: source.query, hash: source.hash };
}
// -- src/router/index.ts --
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import PricingView from '../views/PricingView.vue';
import LoginView from '../views/LoginView.vue';
import AccountView from '../views/AccountView.vue';
import { isSignedIn } from '../auth/isSignedIn'; // your auth check
import { keepQuery } from './keepQuery';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: HomeView },
{ path: '/pricing', component: PricingView },
{ path: '/login', component: LoginView },
{ path: '/account', component: AccountView, meta: { requiresAuth: true } },
{ path: '/plans', redirect: '/pricing' },
{
path: '/offer/:code',
redirect: (to) => ({
path: '/pricing',
query: { ...to.query, offer: to.params.code },
hash: to.hash,
}),
},
],
});
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isSignedIn()) {
return keepQuery(to, '/login');
}
});
export default router;
// -- src/views/*.vue and src/auth/isSignedIn.ts --
// Your existing views and auth check. Nothing in them changes.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.
Set up Amplitude web analytics in this Vue, Vite, and Vue Router project
by following https://amplitude.com/build/vue-web-analytics exactly.
Install @amplitude/unified and call initAll once at module scope in
src/lib/amplitude.ts, reading VITE_AMPLITUDE_API_KEY from
import.meta.env, and import that module first in src/main.ts, before
createApp. Inside analytics: { autocapture } keep attribution, sessions,
and pageUrlEnrichment on, and set pageViews to
{ trackHistoryChanges: 'pathOnly' } unless the router uses
createWebHashHistory, in which case use pageViews: true. Keep the
sessionReplay sampleRate at import.meta.env.PROD ? 0.01 : 1, keep any
debugMode and privacyConfig already in sessionReplay, and tell me how to
turn it off. Don't initialize in onMounted and don't add a
router.afterEach page view hook. Find every navigation guard that returns
a path, every route redirect that sets its own query, and every
router.push or router.replace after sign-in, and make each one carry
to.query and to.hash to the new location. Don't hardcode the key and
don't invent event names. Report every file you changed and every
redirect you checked.What this unlocks
- Session replay, already on from step 2, lets you watch what visitors with
utm_campaign: vue-web-analyticsdid after they landed on/pricing. - Heatmaps, built from those session replays, show where visitors click on
/pricing, and a segment oninitial_utm_sourcesplits paid from organic. - Web Experiment can test a new
/pricinglayout, launched from the Page Engagement table, with your conversion funnel as the metric. - Guides and surveys can ask visitors whose
utm_mediumisemailwhat brought them back.
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.