How to add Web Experiment to a Vue app

Load the Amplitude Web Experiment script in your Vite `index.html` so it runs before Vue mounts, give the elements you want to test stable selectors, and confirm `[Experiment] Impression` events land. About 15 minutes, all copy-paste.

  • Vue 3
  • Vite
  • Vue Router
  • Web Experiment script
  • 15 min

Chris Tam · Last updated 9 October 2026

A Vue app built with Vite renders its whole interface in the browser. Web Experiment lets you A/B test the headlines, buttons, and layouts in that interface from a visual editor, so a test doesn't need a code change and a deploy.

In this tutorial, you'll load the Web Experiment script in index.html, make Vue elements easy to target, and launch your first experiment. It builds on Vue product analytics.

What you'll build

A Vue and Vue Router app that loads API_KEY.experiment.js synchronously at the top of index.html, applies a variant to the home page hero on first load and after client-side navigation, and sends [Experiment] Impression with [Experiment] Flag Key and [Experiment] Variant through the analytics SDK you already run. Project Created from the product analytics page becomes the experiment's goal. By the end you'll see an impression in your own user's event stream in Amplitude.

Prerequisites

  • A Vue 3 app built with Vite, TypeScript, and Vue Router, with Amplitude analytics already initialized in src/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. Web Experiment uses the same project key as your analytics.
  • Your app running at a URL you can open in Chrome, since the Visual Editor loads your real page.

No Amplitude account yet? A free account is enough for this tutorial; the docs list A/B testing and the Visual Editor on all plans, and this page doesn't use custom code actions, which need Growth or Enterprise. Sign up for free to get your project API key.

1. Pick the standalone experiment script

Web Experiment ships as a script tag, not an npm package. The Browser Unified SDK docs are explicit: @amplitude/unified from npm includes Feature Experiment, the code-based flags product, and doesn't include Web Experiment. Installing more packages won't give you the Visual Editor.

Two script tags contain Web Experiment, and on a Vue app with an npm-installed SDK only one is right. Implement Web Experiment documents the first, and the Unified SDK docs document the second:

  • https://cdn.amplitude.com/script/API_KEY.experiment.js loads Web Experiment only. Use it when you already initialize analytics from npm, as the Vue product analytics page does.
  • https://cdn.amplitude.com/script/API_KEY.js, the Unified Script, loads analytics, session replay, and Web Experiment together. Use it only on a site with no Amplitude SDK installed.

Use .experiment.js. The docs say it tracks impressions through the Browser SDK already on your site, so it needs no analytics of its own. Adding the Unified Script next to @amplitude/unified puts a second analytics SDK on the page.

The script URL takes your project API key, which you already have in .env.local. The key is public and its scope is limited to the minimum needed to ingest data, so it's expected in your HTML; keep it in the env file anyway.

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

2. Load it first in index.html

In a Vite project, index.html sits at the project root, next to vite.config.ts, and it's the only HTML the browser receives before your JavaScript runs. That's where the script goes. Put it at the top of <head>, right after <meta charset> and before any stylesheet or module script. Vite (4.2 or later) replaces %VITE_AMPLITUDE_API_KEY% in index.html with the value from your env file when it serves or builds the page.

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <!-- Amplitude Web Experiment. Keep it first after the charset, and synchronous.
         EU projects: https://cdn.eu.amplitude.com/script/ -->
    <script src="https://cdn.amplitude.com/script/%VITE_AMPLITUDE_API_KEY%.experiment.js"></script>
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My app</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Why. The docs ask for the script "as high as possible" in <head> and call the synchronous tag the best experience. Don't move it into a component's onMounted, a Vue plugin, or src/main.ts. Each of those runs after the browser has parsed the page and started your bundle, so visitors see the original content before the variant replaces it. Don't add async or defer to the tag either.

The cost is documented: the base script is about 20 KB compressed plus roughly 100 bytes per experiment, and the browser serves it from cache for 60 seconds across page loads. Archive finished experiments so it doesn't keep growing.

If you can't ship a blocking script

If your performance budget forbids synchronous third-party scripts, the docs offer an async loader with an anti-flicker mask. It hides the page with a style rule, loads the script asynchronously, and clears the mask when Amplitude applies changes or when the timeout expires. Use it in place of the <script src> above, in the same spot:

index.html (async alternative, inside <head>, first after the charset)
<script>
  (function (d, h) {
    var apiKey = "%VITE_AMPLITUDE_API_KEY%";
    // Milliseconds before the mask clears even if the script is slow.
    var timeout = 1000;
    var id = "amp-exp-css";
    try {
      if (!d.getElementById(id)) {
        var st = d.createElement("style");
        st.id = id;
        st.innerText =
          "* { visibility: hidden !important; background-image: none !important; }";
        h.appendChild(st);
        window.setTimeout(function () {
          st.remove();
        }, timeout);
        var sc = d.createElement("script");
        sc.src =
          "https://cdn.amplitude.com/script/" + apiKey + ".experiment.js";
        sc.async = true;
        sc.onerror = function () {
          st.remove();
        };
        h.insertBefore(sc, d.currentScript || h.lastChild);
      }
    } catch (e) {
      console.error(e);
    }
  })(document, document.head);
</script>

The trade is visible: every visitor sees a blank page for up to timeout milliseconds instead of a flash of the original. The synchronous tag is the better default. Don't load Web Experiment through Google Tag Manager in production; the docs warn that tag managers load it asynchronously and cause flicker.

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. The Web Experiment script doesn't go in that plugin: add it through app.head in nuxt.config.ts so it's in the server-rendered <head>. Nuxt reads that config when it builds, so the key comes from process.env; set NUXT_PUBLIC_AMPLITUDE_API_KEY in your build environment.

nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      script: [
        {
          src: `https://cdn.amplitude.com/script/${process.env.NUXT_PUBLIC_AMPLITUDE_API_KEY}.experiment.js`,
          tagPriority: 'critical',
        },
      ],
    },
  },
});

3. Keep analytics in the npm SDK, and keep the device ID stable

The experiment script sends impressions through the analytics SDK you already initialize, so there's no second initAll and no second key. src/lib/amplitude.ts from the Vue product analytics page stays as it is, and import './lib/amplitude'; stays the first line of src/main.ts:

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

That page's config records every session in development and about 1% in production with session replay. Set sessionReplay.sampleRate to 0 if you don't want replays. On Nuxt, the same applies to app/plugins/amplitude.client.ts; the script itself loads from nuxt.config.ts, as shown at the end of step 2.

Check one thing that matters more for experiments than for analytics. The docs say most web experiments bucket by device ID, and reset() issues a new device ID. The base page's useAmplitudeIdentity composable returns early when there's no user and only signOut() calls reset(). Keep it that way. If you've added a reset() call anywhere that runs for signed-out visitors, every anonymous visit gets a fresh device ID and a fresh roll of the dice.

src/composables/useAmplitudeIdentity.ts
import { watch, type Ref } from 'vue';
import * as amplitude from '@amplitude/unified';

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

export function useAmplitudeIdentity(user: Ref<User>) {
  watch(
    () => [user.value?.id, user.value?.plan] as const,
    ([id, plan]) => {
      // Anonymous or still loading: keep the same device ID.
      if (!id) return;

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

      const identify = new amplitude.Identify();
      identify.set('plan', plan ?? 'unknown');
      amplitude.identify(identify);
    },
    { immediate: true },
  );
}
src/auth/signOut.ts
import * as amplitude from '@amplitude/unified';

export async function signOut() {
  // ... your sign-out logic
  // Clears the user ID and issues a new device ID for the next person.
  amplitude.reset();
}

Optional: event-triggered pages and behavioral targeting

If an experiment uses the On Event Tracked page trigger or real-time behavioral targeting, the experiment client needs to hear your analytics events. The docs say that when analytics and Web Experiment don't load together through the Unified Script, you register the experiment plugin on the analytics instance with window.amplitude.add(window.webExperiment.plugin()). With the npm SDK, make the same call on your imported instance, right after initAll. This is the complete updated src/lib/amplitude.ts:

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

type WebExperimentGlobal = {
  plugin: () => Parameters<typeof amplitude.add>[0];
};

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: {
      // 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,
    },
  });

  // Lets On Event Tracked triggers and behavioral targeting see your events.
  const webExperiment = (window as Window & { webExperiment?: WebExperimentGlobal })
    .webExperiment;
  if (webExperiment) {
    amplitude.add(webExperiment.plugin());
  }
} else {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; analytics is off.');
}

export { amplitude };

The synchronous tag from step 2 has already run by the time this module executes, so window.webExperiment exists. With the async loader it may not have loaded yet, and the if skips the plugin without warning. Skip this block entirely if your experiments use the default Immediately trigger.

4. Make Vue elements easy to target

The Visual Editor records each change against a CSS selector, and the script applies it to whatever matches that selector in the live DOM. In a Vue app, your components build that DOM, so three things decide whether a variant sticks.

Give anything you plan to test a stable attribute. The docs warn that the editor picks the shortest unique path, which can include generated names that change between builds. Vue produces two of those: the data-v- attribute that <style scoped> adds to every element in a component, and the hashed class names from <style module>. A data- attribute you write yourself doesn't change:

src/views/HomeView.vue
<script setup lang="ts">
import { RouterLink } from 'vue-router';
</script>

<template>
  <section data-exp="home-hero">
    <h1 data-exp="home-headline">Plan your next project in minutes</h1>
    <p data-exp="home-subhead">Boards, roadmaps, and templates in one place.</p>
    <RouterLink to="/projects/new" data-exp="home-cta">Create a project</RouterLink>
  </section>
</template>

In the editor's Selector field, replace the generated path with [data-exp="home-headline"]. RouterLink passes the attribute through to the <a> it renders. On your own components, a data-exp attribute lands on the root element only when the component has a single root and doesn't set inheritAttrs: false, so put it on a plain element inside the template when in doubt.

Test content Vue doesn't rebuild. Element changes edit an element's text or append styles to it. A change lives on that DOM node, so anything that replaces the node drops it: a v-if that flips, a :key that changes, a parent that re-mounts, or text that arrives late from a fetch or a translation file. The docs' own check is to confirm JavaScript doesn't reset your changes after they apply. Static copy written into the template, like the headline above, is the safe target. For text driven by reactive state, use a feature flag instead.

Wait for elements that render late. The script evaluates on the first page load and every time history state is pushed or popped, which covers router.push() and <RouterLink> navigation. On each change it reverts what it applied, then re-evaluates for the new URL. A lazy route (component: () => import(...)), a <Transition mode="out-in"> around <RouterView>, or a view that waits on data can mount its elements after that moment. If a change doesn't show on a route like that, set the Page's trigger to When Element Appears with the element's selector. Page triggers are in Early Access.

Why. A server-rendered app sends the hero in its first HTML; a Vite app sends an empty <div id="app"> and builds the hero in JavaScript. Every Web Experiment problem specific to Vue comes back to that difference: selectors must survive rebuilds, changes must survive re-renders, and evaluation must find elements that didn't exist when the page loaded.

If your router uses createWebHashHistory, your routes live after the #. The URL Matches targeting rule ignores hash fragments, so it can't tell /#/pricing from /#/. Target those Pages with URL Contains and the hash path, for example #/pricing.

5. Open your headers to the script and editor

Skip this step if your app doesn't send a Content Security Policy or a Cross-Origin-Opener-Policy. Vite's build output is static files and sets neither, so if you have them, they come from your server or host configuration. Per the docs, two changes are required.

Add *.amplitude.com and 'unsafe-inline' to your existing script-src directive so the script and the Visual Editor can load. Remove Cross-Origin-Opener-Policy, or set it to unsafe-none, so the Visual Editor can open your site.

Response headers (merge into the policy you already send)
Content-Security-Policy: script-src 'self' *.amplitude.com 'unsafe-inline'
Cross-Origin-Opener-Policy: unsafe-none

The script-src above is a minimum, not a full policy. If you load the async snippet from step 2, 'unsafe-inline' also covers that inline script. The editor's Mobile Simulator loads your site in an iframe, so X-Frame-Options: DENY or frame-ancestors 'none' blocks it.

6. Create the experiment in the Visual Editor

Run npm run dev or deploy, then create the experiment in Amplitude, following Set up a web experiment:

  1. In Amplitude Experiment, go to Experiments > Create Experiment > Web Experiment.
  2. Name the experiment, enter the URL of your home page, and select your project. Amplitude uses the URL to create your first Page.
  3. When the Visual Editor opens your page, click Element Changes, click the headline, and edit its text. Open the Selector and set it to [data-exp="home-headline"]. Changes apply to the current variant as you go.
  4. Click Continue. Leave the Page targeting on your home page URL and the audience on all users.
  5. Define your goals. Choose Project Created, the event the product analytics page tracks, as the primary metric.
  6. Click Test & Preview and open the preview link to check the treatment, then start the experiment.

If Amplitude opens a Site Setup panel instead of the editor, it didn't find the script on that URL, or an ad blocker in your browser stopped it. After you save changes, allow up to 60 seconds for caches to refresh before you check the live page.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy, so when you pick a goal it points at Project Created instead of inventing project_created. Set up the MCP server.

Verify it worked

Open your home page in a normal Chrome window with ad blockers off, click a <RouterLink> to another route and back, then check in this order:

  1. Network tab. Filter for amplitude.com. You should see API_KEY.experiment.js from cdn.amplitude.com return 200 with your real key in the URL, not the literal %VITE_AMPLITUDE_API_KEY%, and loaded before main.ts. Then look for the analytics SDK's POST to api2.amplitude.com/2/httpapi. If you're in the experiment, the Instrumentation Explorer Chrome extension shows an event with event_type $impression, the form the script sends before Amplitude transforms it.
  2. 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.
  3. User lookup. Go to Users > User Profiles and search your user- prefixed test ID or your device ID. Within ten seconds to a minute the event stream shows [Experiment] Impression, with [Experiment] Flag Key naming your experiment and [Experiment] Variant naming the variant you saw.

No impression and no variant? You may be in control or outside the rollout. A private window gets a new device ID and a new roll. The Amplitude Chrome extension shows page and audience targeting and exposure status for each web experiment on the page, and Preview Variant lets you force a variant for your own session.

Troubleshooting

The original headline flashes before the variant

The script is loading late. It must be a plain <script src> at the top of <head> in index.html, right after <meta charset>, not added from a component, a Vue plugin, main.ts, or a tag manager, and without async or defer.

The script request 404s with %VITE_AMPLITUDE_API_KEY% in the URL

Vite left the %...% string in place because the variable isn't set, or because the project runs a Vite version older than 4.2. Check the VITE_ prefix and restart the dev server, since Vite reads .env.local at startup. For production builds, set the variable in your build environment and rebuild; the key is written into index.html at build time.

The variant applies on the home page but not on a route you navigate to

The element mounts after the route change, behind a lazy route, a <Transition>, or a data fetch. First confirm the selector matches the rendered element: run document.querySelector('[data-exp="home-headline"]') in the DevTools console. If it does, switch that Page's trigger to When Element Appears and select the element by its data-exp attribute.

The variant shows, then the original comes back

Vue replaced the element. A v-if, a changed :key, or a re-mounted parent creates a new node without the change. Pick an element that renders once from static template content, or move the test to a feature flag.

The variant worked yesterday and stopped after a deploy

The selector included a data-v- scoped style attribute or a <style module> class name that changed in the new build. Edit the change in the Visual Editor and set its selector to the data-exp attribute.

Anonymous visitors see a different variant on every visit

Something calls amplitude.reset() while the user is signed out, which issues a new device ID. Reset only in signOut(), and keep the early return in useAmplitudeIdentity.

Page views or sessions doubled after adding the script

You loaded the Unified Script, API_KEY.js, next to the npm SDK. Replace it with API_KEY.experiment.js.

Full code reference

Every file from this tutorial in one block, the version to hand to an agent. It uses the synchronous tag; swap in the async alternative from step 2 only if you must. For the optional webExperiment.plugin() registration, use the src/lib/amplitude.ts from step 3 instead. 7 files.

8 files, 101 lines
// -- .env.local --
VITE_AMPLITUDE_API_KEY=your-project-api-key

// -- index.html --
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <!-- Amplitude Web Experiment. Keep it first after the charset, and synchronous.
         EU projects: https://cdn.eu.amplitude.com/script/ -->
    <script src="https://cdn.amplitude.com/script/%VITE_AMPLITUDE_API_KEY%.experiment.js"></script>
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My app</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

// -- 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: {
      // 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/composables/useAmplitudeIdentity.ts --
import { watch, type Ref } from 'vue';
import * as amplitude from '@amplitude/unified';

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

export function useAmplitudeIdentity(user: Ref<User>) {
  watch(
    () => [user.value?.id, user.value?.plan] as const,
    ([id, plan]) => {
      if (!id) return;
      // Amplitude drops IDs shorter than 5 characters.
      amplitude.setUserId(`user-${id}`);
      const identify = new amplitude.Identify();
      identify.set('plan', plan ?? 'unknown');
      amplitude.identify(identify);
    },
    { immediate: true },
  );
}

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

export async function signOut() {
  // ... your sign-out logic
  amplitude.reset();
}

// -- src/views/HomeView.vue --
<script setup lang="ts">
import { RouterLink } from 'vue-router';
</script>

<template>
  <section data-exp="home-hero">
    <h1 data-exp="home-headline">Plan your next project in minutes</h1>
    <p data-exp="home-subhead">Boards, roadmaps, and templates in one place.</p>
    <RouterLink to="/projects/new" data-exp="home-cta">Create a project</RouterLink>
  </section>
</template>

// -- src/App.vue, src/router/index.ts, src/views/NewProject.vue --
// Unchanged from /build/vue-product-analytics.

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 Web Experiment to this Vue + Vite + Vue Router app by
following https://amplitude.com/build/vue-web-experiment exactly. Add
<script src="https://cdn.amplitude.com/script/%VITE_AMPLITUDE_API_KEY%.experiment.js"></script>
as the first tag after <meta charset> inside <head> in the root
index.html, synchronous, with no async or defer. Do not load it from a component, onMounted, a Vue
plugin, main.ts, or a tag manager. Do not add the Unified Script
(API_KEY.js) and do not install another npm package for Web Experiment;
@amplitude/unified doesn't include it. Keep analytics in the existing
src/lib/amplitude.ts and don't call initAll a second time. Make sure no
amplitude.reset() call runs when the user is null; call reset() only in
the sign-out handler. Add data-exp attributes to the elements I name for
testing, on plain elements or RouterLink, never relying on data-v- or
CSS module class names. If the app sends Content-Security-Policy or
Cross-Origin-Opener-Policy headers, add *.amplitude.com and
'unsafe-inline' to script-src and set COOP to unsafe-none. Don't hardcode
the key. Report every file you changed.

What this unlocks

  • Session replay, on at the sample rate from the product analytics page, shows sessions where visitors saw the treatment variant and Project Created never fired, so you can watch why.
  • Heatmaps are built from session replays, so they compare where people click on the home page hero in each variant.
  • Feature Experiment, already in @amplitude/unified, puts the winning headline in HomeView.vue behind a flag when you want a gradual rollout, since the docs recommend moving a winner into code rather than leaving the web experiment at 100%.
  • Guides and surveys can ask visitors who saw the treatment what they expected after clicking home-cta.

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.