How to add session replay to a Vue app

Turn on Amplitude session replay in a Vue, Vite, and Vue Router app, record every session in development and a small share in production, mask sensitive components and whole routes, and open your own test replay. About 15 minutes, all copy-paste.

  • Vue 3
  • Vite
  • Vue Router
  • Unified SDK 1.x
  • 15 min

Chris Tam · Last updated 9 October 2026

Bug reports rarely include the state that caused them. Session replay records what each user saw and did, so "the button didn't work" becomes a session you can watch.

In this tutorial, you'll turn on session replay in a Vue and Vite app, set a sample rate per environment, mask sensitive components and routes, and confirm a replay lands. It builds on How to add product analytics to a Vue app.

What you'll build

A Vue app that records every session in development and 1% of sessions in production, masks text inputs by default, masks anything wrapped in an AmpMask component and every screen under /billing, replaces blocked elements with a placeholder, and tags the session's events with [Amplitude] Session Replay ID. By the end you'll play back your own test session from your event stream in Amplitude.

Prerequisites

  • A Vue 3 app with Vite, TypeScript, and Vue Router, for example one created with npm create vue@latest.
  • An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
  • To change sampling or masking in Amplitude's settings, an Admin or Manager role. The code on this page doesn't need it.

No Amplitude account yet? Session replay for web is available on all plans, so a free account covers this tutorial. Sign up for free to get your project API key.

1. Install the SDK

The Quickstart recommends @amplitude/unified for browser apps installed from npm. It bundles analytics, session replay, Feature Experiment, and guides and surveys behind one initAll call, and session replay records once sessionReplay.sampleRate is above 0. If you followed the Vue product analytics page, it's already installed; skip to step 2.

Terminal
npm install @amplitude/unified

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 anyway.

.env.local
VITE_AMPLITUDE_API_KEY=your-project-api-key

Already on @amplitude/analytics-browser to keep bundle size down? The session replay plugin takes the same options as the sessionReplay block below. Add it to your existing init rather than installing a second SDK.

2. Set a sample rate per environment

Initialize once, at module scope, and choose the sample rate from Vite's build mode. This file replaces src/lib/amplitude.ts from the Vue product analytics page; the analytics block is unchanged.

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: true,
        // Tracks [Amplitude] Start Session and End Session events.
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      // Fraction of sessions to record: every session in `vite dev`, 1% in a production build.
      sampleRate: import.meta.env.PROD ? 0.01 : 1,
      // Ignores the focus check so replays record with DevTools open. Setup only.
      debugMode: !import.meta.env.PROD,
      // Selectors for the data attributes in steps 3 and 4.
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
    },
  });
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

export { amplitude };

Import it first in your entry file, before createApp. If you followed the base page, this file is already in place:

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');

Why. Session replay takes a full snapshot of the page when it initializes, and nothing that happens before that point appears in the replay. A module body runs once per page load, before createApp, so recording starts as early as the app allows. It also matters for matching: initializing the SDK more than once can leave the replay and the session's events with different device or session IDs, and a replay that doesn't match its events is one you can't open from the event stream. An initAll inside a component's onMounted runs again every time that component mounts.

Later pages in this hub build on this file. If you copy a version of src/lib/amplitude.ts that lacks privacyConfig, keep this block.

The plugin's own default sample rate is 0, so nothing records until you set one. Amplitude recommends starting production low, around 0.01, and raising it over a few days; capture stops for the month once your org's quota runs out. To stop recording, set the rate to 0. Session Replay Settings in Amplitude can also set a project's rate remotely, so check Settings > Organizational Settings > Session Replay Settings when the rate you see doesn't match your code. For every option, see the session replay plugin configuration; the Unified SDK accepts all of them inside sessionReplay except serverZone and instanceName, which go at the top level of the initAll options.

Unified also initializes guides and surveys by default. If you won't use them, the Unified SDK docs describe an engagement: { skip: true } option that stops that SDK from loading.

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. Pass the same sessionReplay block with Nuxt's dev flag; the AmpMask component and data attributes from step 3 work unchanged. For step 4, wrap <NuxtPage /> in <AmpMask> in app/pages/billing.vue (pages/billing.vue on Nuxt 3) and put the child screens in app/pages/billing/.

app/plugins/amplitude.client.ts
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: true,
        sessions: true,
        formInteractions: true,
        fileDownloads: true,
        elementInteractions: true,
      },
    },
    sessionReplay: {
      sampleRate: import.meta.dev ? 1 : 0.01,
      debugMode: import.meta.dev,
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
    },
  });
});

3. Mask sensitive elements in Vue templates

Session replay masks every text input by default: typed characters arrive as asterisks. The project's default masking level, Medium, masks all form fields and text inputs and captures other text as-is. Everything else is your call, element by element.

The SDK recognizes three classes out of the box, amp-mask, amp-block, and amp-unmask, and Vue merges a static class with a :class binding, so they work in templates. The data attributes registered in step 2 keep privacy separate from styling, and they're what the wrapper component below sets. Put one small component in charge of masking, so every template says it the same way:

src/components/AmpMask.vue
<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    // Replace the content with a same-size placeholder instead of asterisks.
    block?: boolean;
    // Use 'span' inside inline content.
    tag?: string;
  }>(),
  { block: false, tag: 'div' },
);
</script>

<template>
  <component
    :is="props.tag"
    :data-amp-mask="props.block ? undefined : ''"
    :data-amp-block="props.block ? '' : undefined"
  >
    <slot />
  </component>
</template>

Use it in any template, next to plain data attributes where a wrapper would be overkill:

src/views/TeamView.vue
<script setup lang="ts">
import { ref } from 'vue';
import AmpMask from '../components/AmpMask.vue';

type Member = { id: string; name: string; email: string; avatarUrl: string };

// Replace with data from your API.
const members = ref<Member[]>([
  { id: '1', name: 'Test User', email: 'test@example.com', avatarUrl: 'https://placehold.co/32' },
]);
</script>

<template>
  <main>
    <h1>Team</h1>

    <!-- Inputs are masked by default. This one is safe to watch. -->
    <input data-amp-unmask name="filter" placeholder="Filter by name" />

    <ul>
      <li v-for="member in members" :key="member.id" :class="$style.row">
        <!-- Replaced by a same-size placeholder -->
        <AmpMask block tag="span">
          <img :src="member.avatarUrl" alt="" />
        </AmpMask>
        <span>{{ member.name }}</span>
        <!-- Captured as asterisks -->
        <AmpMask tag="span" :class="$style.email">{{ member.email }}</AmpMask>
      </li>
    </ul>
  </main>
</template>

<style module>
.row { display: flex; gap: 0.5rem; }
.email { color: #555; }
</style>

Vue renders a bare data-amp-unmask and the component's '' value as data-amp-unmask="" and data-amp-mask="", which the attribute selectors match. Binding undefined leaves the attribute off.

Why. The wrapper puts the attribute on an element you can see in the template. Writing data-amp-mask on a child component's tag, such as <MemberEmail data-amp-mask />, relies on Vue passing it through to that component's root element, which doesn't happen when the component has several root nodes or sets inheritAttrs: false, and nothing in the replay tells you. Masking also follows the DOM, not the component tree: content a component moves with <Teleport to="body">, such as a modal, leaves its masked parent, so wrap the teleported content itself. And masking covers text and form inputs, not HTML attributes. alt, title, placeholder, aria-label, value, and data-* attributes stay visible in replays even inside a masked element, so a tooltip bound as :title="member.email" leaks the address no matter what you mask.

Don't target <style module> or scoped style class names as masking selectors, in code or in Session Replay Settings. They're generated at build time and can change between builds, at which point the rule stops matching without any error.

When the masking rules in Session Replay Settings and the SDK disagree about an element, the settings page wins. You can change the project's level (Light, Medium, Conservative) or add selector overrides there, per project; see Manage privacy settings for session replay.

4. Mask whole routes with a nested route

Some screens shouldn't show any text in a replay, such as billing, invoices, or a patient record. Masking each element there is how one gets missed. A Vue Router parent route with a layout component gives you one wrapper for a whole section:

src/layouts/MaskedLayout.vue
<script setup lang="ts">
import AmpMask from '../components/AmpMask.vue';
</script>

<template>
  <!-- Everything rendered under this route is masked, including screens added later. -->
  <AmpMask>
    <RouterView />
  </AmpMask>
</template>

Add two screens for the section, so there's text to check in the replay. If you already have billing screens, use those instead:

src/views/BillingView.vue
<template>
  <h1>Billing</h1>
  <p>Card ending 4242, billed to Test User</p>
</template>
src/views/InvoicesView.vue
<template>
  <h1>Invoices</h1>
</template>

Then register the layout as a parent route:

src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import TeamView from '../views/TeamView.vue';
import MaskedLayout from '../layouts/MaskedLayout.vue';
import BillingView from '../views/BillingView.vue';
import InvoicesView from '../views/InvoicesView.vue';

export default createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', component: HomeView },
    { path: '/team', component: TeamView },
    {
      path: '/billing',
      component: MaskedLayout,
      children: [
        { path: '', component: BillingView },
        { path: 'invoices', component: InvoicesView },
      ],
    },
  ],
});

Keep the rest of your routes as they are. If your App.vue already calls useAmplitudeIdentity from the Vue product analytics page, leave it there.

Why. Masking an element through a selector masks all of its children, and a child inside it can't be unmasked again. That's what you want here: a developer who adds /billing/payment-methods next year gets masking without knowing this file exists. It also means data-amp-unmask does nothing under /billing, so put content you want visible outside the nested route.

The layout masks text, not structure. Clicks, scrolling, and layout still record, so you can follow what the user did on /billing without reading it. To hide the region entirely, use <AmpMask block> in the layout instead.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server includes get_session_replays, and the debug-replay skill in Amplitude's MCP marketplace plugin turns a bug report into numbered reproduction steps from the replay's interaction timeline, so you don't have to scrub through sessions by hand. Set up the MCP server.

5. Allow Amplitude in your CSP

A Vite app sends no Content-Security-Policy by default. Skip this step unless you added one, in a <meta> tag in index.html or in your host's response headers. If you did, add Amplitude's hosts to the connect-src directive you already have:

Add to your existing Content-Security-Policy (US data center)
connect-src https://api2.amplitude.com https://api-sr.amplitude.com https://sr-client-cfg.amplitude.com;

api-sr.amplitude.com receives replay data and sr-client-cfg.amplitude.com serves the session replay remote configuration. api2.amplitude.com is where the analytics SDK sends events. EU projects use api-sr.eu.amplitude.com and sr-client-cfg.eu.amplitude.com and set serverZone: 'EU' at the top level of the initAll options. Add worker-src blob: only if you turn on useWebWorker. Unified also loads guides and surveys by default, which needs https://*.amplitude.com in script-src, connect-src, img-src, media-src, and style-src. Allow those, or turn guides and surveys off with engagement: { skip: true }.

Why. If remote configuration fails to load, session replay captures nothing, on purpose, so it never records without the privacy settings you defined in Amplitude. A blocked config request looks like "replay does nothing" rather than like an error, until you open the console and find a Content Security Policy message.

Verify it worked

Run npm run dev, open http://localhost:5173 in a fresh private window, visit /team, type into the filter, open /billing, then check in this order:

  1. Console and Network tab. No Content Security Policy errors in the console. In the Network tab, filter for amplitude: you should see event uploads to api2.amplitude.com returning 200, a request to sr-client-cfg.amplitude.com, and replay uploads to api-sr.amplitude.com.
  2. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your event uploads. It shows counts, not individual events.
  3. User lookup. Go to Users > User Profiles and search your device ID (the Instrumentation Explorer Chrome extension shows it as device_id) or your user ID if you're signed in. Within ten seconds to a minute, the session's events, such as [Amplitude] Start Session, carry [Amplitude] Session Replay ID, and the event stream shows a Play Session button next to that session. Replays are generally ready about five minutes after the session begins.
  4. Session Replay debug panel. Install the Instrumentation Explorer Chrome extension and turn on its Session Replay debug panel on your page. It floats over the page and shows the current replay ID, the session replay SDK and plugin versions, the sample rate, and the default and effective masking settings. A sample rate of 1 in development confirms step 2 took effect, and the effective masking settings show whether Session Replay Settings overrode your code.
  5. The replay itself. Play it. On /team, the filter text is visible, emails are asterisks, and avatars are grey placeholders. On /billing, every line of text is masked while clicks and scrolling still show.

If the replay won't load, open the Ingestion Monitor from the top right of the Session Replay section of your project's Users & Sessions tab. It shows replay request statuses, including 429 throttling and 402 quota errors.

To force a fresh session while testing, delete the cookies that start with AMP_ in DevTools and reload.

Replays recorded against localhost can play back unstyled, because Amplitude can't fetch files from your machine. Check styling against a deployed preview.

Troubleshooting

No session replay requests at all

The effective sample rate is 0. Check sessionReplay.sampleRate, then the rate in Session Replay Settings; the debug panel shows the rate the SDK is using. A production build (vite build, then vite preview) records only about one session in a hundred at 0.01, so test in vite dev or raise the rate temporarily.

Replays record in production but not while you debug

Session replay only captures the tab that has focus, and an open DevTools panel can take it. Keep debugMode: !import.meta.env.PROD from step 2, which ignores the focus check in development.

Events arrive but the session has no Play Session button

No event in that session carries [Amplitude] Session Replay ID. The usual cause is a second initAll or init call, for example inside a component's onMounted or a Vue plugin's install, so the replay and the events disagree on device or session ID. Search the codebase and keep the one in src/lib/amplitude.ts.

Masked text shows up in a replay

Check four things in order. The text sits in an attribute such as title or aria-label, which masking never covers. The data-amp-mask sits on a child component's tag and never reached the DOM; inspect the element, then wrap the content in AmpMask. The text renders through <Teleport> outside the masked wrapper. Or the element was unmasked in Session Replay Settings, which overrides the SDK; the debug panel's effective masking settings show it.

Old replays lose their styling after a deploy

Vite writes CSS to hashed file names under dist/assets/, and a deploy that replaces the folder removes the old ones. Keep previous build assets available on your host, allow app.amplitude.com in your asset server's CORS policy, and add crossorigin="anonymous" to any external <link rel="stylesheet">.

Requests fail with ERR_BLOCKED_BY_CLIENT

An ad blocker is dropping them. Session replay isn't compatible with ad blocking software, so test in a clean profile. For production traffic, route requests through your own domain and point trackServerUrl and configServerUrl at it.

No requests to Amplitude at all

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.

Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 9 files.

10 files, 144 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;

if (apiKey) {
  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,
      privacyConfig: {
        maskSelector: ['[data-amp-mask]'],
        blockSelector: ['[data-amp-block]'],
        unmaskSelector: ['[data-amp-unmask]'],
      },
    },
  });
} 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/components/AmpMask.vue --
<script setup lang="ts">
const props = withDefaults(
  defineProps<{ block?: boolean; tag?: string }>(),
  { block: false, tag: 'div' },
);
</script>

<template>
  <component
    :is="props.tag"
    :data-amp-mask="props.block ? undefined : ''"
    :data-amp-block="props.block ? '' : undefined"
  >
    <slot />
  </component>
</template>

// -- src/layouts/MaskedLayout.vue --
<script setup lang="ts">
import AmpMask from '../components/AmpMask.vue';
</script>

<template>
  <AmpMask>
    <RouterView />
  </AmpMask>
</template>

// -- src/router/index.ts --
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import TeamView from '../views/TeamView.vue';
import MaskedLayout from '../layouts/MaskedLayout.vue';
import BillingView from '../views/BillingView.vue';
import InvoicesView from '../views/InvoicesView.vue';

export default createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', component: HomeView },
    { path: '/team', component: TeamView },
    {
      path: '/billing',
      component: MaskedLayout,
      children: [
        { path: '', component: BillingView },
        { path: 'invoices', component: InvoicesView },
      ],
    },
  ],
});

// -- src/views/BillingView.vue --
<template>
  <h1>Billing</h1>
  <p>Card ending 4242, billed to Test User</p>
</template>

// -- src/views/InvoicesView.vue --
<template>
  <h1>Invoices</h1>
</template>

// -- src/views/TeamView.vue --
<script setup lang="ts">
import { ref } from 'vue';
import AmpMask from '../components/AmpMask.vue';

type Member = { id: string; name: string; email: string; avatarUrl: string };

// Replace with data from your API.
const members = ref<Member[]>([
  { id: '1', name: 'Test User', email: 'test@example.com', avatarUrl: 'https://placehold.co/32' },
]);
</script>

<template>
  <main>
    <h1>Team</h1>
    <input data-amp-unmask name="filter" placeholder="Filter by name" />
    <ul>
      <li v-for="member in members" :key="member.id" :class="$style.row">
        <AmpMask block tag="span">
          <img :src="member.avatarUrl" alt="" />
        </AmpMask>
        <span>{{ member.name }}</span>
        <AmpMask tag="span" :class="$style.email">{{ member.email }}</AmpMask>
      </li>
    </ul>
  </main>
</template>

<style module>
.row { display: flex; gap: 0.5rem; }
.email { color: #555; }
</style>

// -- src/App.vue, src/views/HomeView.vue --
// Your existing files. 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.

Using Cursor or Claude Code? Paste this prompt
Add Amplitude session replay to this Vue + Vite + Vue Router app by
following https://amplitude.com/build/vue-session-replay exactly. If the
app already uses @amplitude/analytics-browser, add
@amplitude/plugin-session-replay-browser to the existing init instead of
installing a second SDK, pass the same options to
sessionReplayPlugin({...}), and call amplitude.add() with it before
amplitude.init(). Otherwise use
@amplitude/unified. 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. Never
initialize in onMounted or a component. Set sessionReplay.sampleRate to
import.meta.env.PROD ? 0.01 : 1, with debugMode only in development, and
register [data-amp-mask], [data-amp-block], and [data-amp-unmask] in
sessionReplay.privacyConfig. Create src/components/AmpMask.vue, a wrapper
that renders data-amp-mask (or data-amp-block with a block prop) around a
slot, and wrap elements that render emails, billing names, or account data
in it. Wrap images of people or documents in <AmpMask block>. Put billing
and any other sensitive route group under a parent route whose component
renders <AmpMask><RouterView /></AmpMask>, and keep any other routes
you already have. Don't put masking attributes on
child component tags, wrap teleported content separately, and never use
CSS Modules or scoped class names as masking selectors. Flag any title,
aria-label, alt, or placeholder binding that contains personal data. If
this is a Nuxt app, initialize in app/plugins/amplitude.client.ts instead.
Don't hardcode the key. Report every file you changed and any CSP you
found.

What this unlocks

  • Session replay sits on the same events as your charts: open a funnel step that ends at /billing and watch the sessions where users dropped, text masked.
  • Heatmaps are built from session replays, so once heatmap capture is on for the project in Session Replay Settings, clicks and scrolls on /team aggregate without new code.
  • Feature Experiment, bundled in @amplitude/unified, lets you filter replays by variant and see how people use a new version of /team.
  • Guides and surveys, also bundled, can ask the users whose replays show a stall on /billing what they were looking for.

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.