---
title: "How to add feature flags and experiments to a Vue app"
description: "Create a flag with a JSON payload, read it through a Vue composable, tie exposures to signed-in users, and keep anonymous visitors in one bucket."
canonical: "https://amplitude.com/en-us/build/vue-feature-flags"
---

# How to add feature flags and experiments to a Vue app

Create a flag with a JSON payload, read it through a Vue composable, tie exposures to signed-in users, and keep anonymous visitors in one bucket.

Source: https://amplitude.com/en-us/build/vue-feature-flags

---

# How to add feature flags and experiments to a Vue app

Create a client deployment and a feature flag with a JSON payload, read the variant through a \`useFlag\` composable in a Vue and Vite app, refetch when the user signs in, and confirm the exposure lands on the right user. About 15 minutes, all copy-paste.

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

Chris Tam · Last updated 9 October 2026

[Feature flags](https://amplitude.com/feature-management) let you ship a Vue feature switched off, roll it out to some users, and change it without a redeploy. Run the flag as [an experiment](https://amplitude.com/amplitude-experiment) and you learn whether it moved your metric.

In this tutorial, you'll create a flag with a JSON payload, read it through a Vue composable, and tie exposures to signed-in users. It builds on the [Vue product analytics page](/build/vue-product-analytics).

## What you'll build

A Vue app where the `/projects/new` view reads the `new-project-flow` flag through a `useFlag` composable, shows a template gallery with a preselected template for users in the `on` variant, and sends one `[Experiment] Exposure` event for the signed-in user when the view mounts. `Project Created` keeps firing with its `template` property, so it can serve as the experiment's metric. By the end you'll see the exposure in that user's event stream in Amplitude.

## Prerequisites

- A Vue 3 app with Vite, TypeScript, and Vue Router, set up as on the [Vue product analytics page](/build/vue-product-analytics), with a `/projects/new` route that renders `src/views/NewProject.vue`.
- Your Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.
- Access to Experiment in the same Amplitude project, so you can create a deployment and a flag.

No Amplitude account yet? Feature flags are available on all plans, so a free account covers the steps on this page. Running the flag as an experiment and analyzing the results depends on your plan; [compare plans](https://amplitude.com/pricing). [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Create a client deployment and a flag

Experiment has its own keys. A deployment serves a group of flags to one platform, and each deployment has a deployment key. A client deployment key is publicly viewable and belongs in browser code. A server deployment key is secret and belongs only on a server. Your project API key is a third, separate key that sends analytics events. The [deployment docs](https://amplitude.com/docs/feature-experiment/workflow/configure) cover each type.

Create the deployment first:

1. Go to Experiment > Deployments, then click **Create Deployment**.
2. Choose the same Amplitude project your analytics events go to. The deployment key has to live in the project you send events to, or exposures won't line up with users.
3. Name it after the platform, for example `web`.
4. Choose **Client-side** as the type, click **Create a Deployment**, and copy the key it generates. Client keys start with `client-`.

Then create the flag:

1. Go to Experiment > Feature Flags and click **Create A Feature Flag**.
2. Choose the same project and name the flag `New project flow`. Experiment generates the flag key `new-project-flow` from the name; that key is what your code reads.
3. Leave the evaluation mode on **Remote**, set the bucketing unit to the user, and click **Create**.
4. Choose the `web` deployment from the **Deployment** drop-down.
5. Add a variant named `on`. In its **Payload** field, paste the JSON below, then click **Apply**.
6. In the All Other Users section, set the rollout to 50% so you can see both branches. Save, then set the flag to **Active**.

Payload for the `on` variant:

Payload for the on variant

```json
{
  "defaultTemplate": "kanban",
  "showTemplateGallery": true
}
```

The payload is what makes the flag more than an on/off switch: your code reads `defaultTemplate` and `showTemplateGallery`, so you can change which template is preselected from Amplitude later without shipping a build. Don't name a variant `off`; Experiment reserves it for users who fall outside the rollout. Payloads are visible in network traffic, so keep them to configuration and out of anything sensitive.

## 2. Install the SDK and add both keys

[The Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) recommends `@amplitude/unified` for browser apps installed from npm. Per the [Browser Unified SDK docs](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk), it bundles analytics, session replay, guides and surveys, and Feature Experiment (`@amplitude/experiment-js-client`) behind one `initAll` call. If you followed the Vue product analytics page, it's already installed.

Terminal

```sh
npm install @amplitude/unified
```

Vite only exposes env vars prefixed with `VITE_` to browser code. Both values below are meant for the browser: the project API key is public and its scope is limited to the minimum needed to ingest data, and a client deployment key is public by design. Keep them in `.env.local` anyway, so a fork or a public repo doesn't make them permanent. Never put a server deployment key in a `VITE_` variable, because Vite inlines it into the bundle anyone can download.

.env.local

```sh
VITE_AMPLITUDE_API_KEY=your-project-api-key
VITE_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key
```

The npm package includes Feature Experiment, the code-based flags this page uses. It doesn't include Web Experiment, the visual editor; that one loads from a script tag, covered in [How to add Web Experiment to a Vue app](/build/vue-web-experiment).

## 3. Initialize once and fetch flags at startup

This file replaces `src/lib/amplitude.ts` from the Vue product analytics page. It initializes at module scope, starts the first flag fetch right away, and exposes a read-only `flagsVersion` ref that goes up each time a fetch settles, so composables can watch it and reread the variant.

src/lib/amplitude.ts

```typescript
import { readonly, ref } from 'vue';
import * as amplitude from '@amplitude/unified';

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

type FetchUser = { user_properties?: Record<string, string | number | boolean> };

// Goes up by one each time a flag fetch settles. 0 means the first
// fetch hasn't settled yet.
const version = ref(0);
export const flagsVersion = readonly(version);

let fetchGeneration = 0;

// initAll is typed void; wrapping it keeps the await below safe either way.
const ready: Promise<unknown> = apiKey
  ? Promise.resolve(
      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,
        },
        experiment: {
          // Falls back to the project API key if unset.
          deploymentKey: import.meta.env.VITE_AMPLITUDE_DEPLOYMENT_KEY,
          // Default is 10 seconds. Fall back to the current UI sooner.
          fetchTimeoutMillis: 3000,
        },
      }),
    )
  : Promise.resolve();

if (!apiKey) {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; Amplitude is off.');
}

async function refetchFlags(user?: FetchUser) {
  const generation = ++fetchGeneration;
  try {
    await ready;
    // Remote evaluation. On failure or timeout the SDK returns and keeps
    // retrying in the background, so this always settles.
    await amplitude.experiment()?.fetch(user);
  } finally {
    // Only the newest fetch updates components, so an anonymous fetch that
    // finishes after sign-in can't overwrite the signed-in result.
    if (generation === fetchGeneration) version.value += 1;
  }

// First fetch, for whoever is on this device right now.
void refetchFlags();

export function identifyUser(userId: string, userProperties: Record<string, string>) {
  if (!apiKey) return;
  amplitude.setUserId(userId);
  const identify = new amplitude.Identify();
  Object.entries(userProperties).forEach(([key, value]) => identify.set(key, value));
  amplitude.identify(identify);
  // Pass the properties to fetch too, so targeting sees them on this request.
  void refetchFlags({ user_properties: userProperties });
}

export function resetUser() {
  if (!apiKey) return;
  amplitude.reset(); // clears the user ID and issues a new device ID
  amplitude.experiment()?.clear(); // drops the previous user's variants
  void refetchFlags();
}

export { amplitude };
```

Keep `import './lib/amplitude';` as the first line of `src/main.ts`, as on the Vue product analytics page:

src/main.ts

```typescript
import './lib/amplitude';
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';

createApp(App).use(router).mount('#app');
```

**Why.** A Vite app has no server render, so the variant request can only start in the browser. Starting it at module scope, before `createApp`, gives it a head start on mounting instead of waiting for a component's `onMounted`. A module body runs once per page load, so `initAll` runs once. The `flagsVersion` ref lives in a plain module rather than a component, which is fine: Vue's `ref` works outside components, and every composable that reads it shares the same value.

`sessionReplay.sampleRate` carries over from the product analytics page: every session in development and about 1% in production. Set it to `0` to turn session replay off. Unified also initializes guides and surveys by default; the [Unified SDK docs](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk) describe `engagement: { skip: true }` to stop it loading. For every Experiment option, see the [Experiment JavaScript SDK configuration](https://amplitude.com/docs/sdks/experiment-sdks/experiment-javascript#configuration).

On Nuxt, initialize in `app/plugins/amplitude.client.ts` (`plugins/` on Nuxt 3) as on [How to add product analytics to a Nuxt app](/build/nuxt-product-analytics), and keep SDK calls out of the server render. Add the same `experiment` block to its `initAll` options, and call `fetch()` and `variant()` only in the browser. If you want the server render to already have the variant, so the first paint doesn't wait on a browser fetch, follow [How to add feature flags and experiments to a Nuxt app](/build/nuxt-feature-flags) instead.

## 4. Read the variant with a composable

`variant()` reads from the SDK's local store and, through the analytics integration, tracks an exposure each time you call it. So call it in the component that shows the variant, once that component has mounted, and nowhere else.

src/composables/useFlag.ts

```typescript
import { computed, onMounted, shallowRef, watch } from 'vue';
import { amplitude, flagsVersion } from '../lib/amplitude';

type FlagState = { value: string | null; payload: unknown };

export function useFlag(flagKey: string) {
  const flag = shallowRef<FlagState | undefined>(undefined);

  function read() {
    if (flagsVersion.value === 0) return; // the first fetch hasn't settled yet
    // Reading the variant here is what ties the exposure to someone who
    // actually reached this view.
    const variant = amplitude.experiment()?.variant(flagKey);
    flag.value = { value: variant?.value ?? null, payload: variant?.payload };
  }

  // A fetch can settle between setup and mount. Skip it then, because
  // onMounted reads anyway, so the exposure only fires after mount.
  let mounted = false;
  onMounted(() => {
    mounted = true;
    read(); // flags already fetched before this view opened
  });
  watch(flagsVersion, () => {
    if (mounted) read(); // a fetch settles while the view is open
  });

  return {
    loading: computed(() => flag.value === undefined),
    value: computed(() => flag.value?.value ?? null),
    payload: computed(() => flag.value?.payload),
  };
}
```

The template list lives in its own module, so the view and the form share it:

src/lib/templates.ts

```typescript
export const TEMPLATES: string[] = ['blank', 'kanban', 'roadmap'];
```

The view reads the flag, waits for the first fetch, and only then mounts the form, passing the payload in as props:

src/views/NewProject.vue

```html
<script setup lang="ts">
import { computed } from 'vue';
import { useFlag } from '../composables/useFlag';
import { TEMPLATES } from '../lib/templates';
import NewProjectForm from '../components/NewProjectForm.vue';

type NewProjectPayload = {
  defaultTemplate?: unknown;
  showTemplateGallery?: unknown;
};

const { loading, value, payload } = useFlag('new-project-flow');

// Only the "on" variant carries the new settings. Validate the payload
// and keep the current defaults for anything missing or unexpected.
const config = computed(
  () => (value.value === 'on' ? payload.value : undefined) as NewProjectPayload | undefined,
);

const defaultTemplate = computed(() => {
  const candidate = config.value?.defaultTemplate;
  return typeof candidate === 'string' && TEMPLATES.includes(candidate) ? candidate : 'blank';
});

const showGallery = computed(() => config.value?.showTemplateGallery === true);
</script>

<template>
  <div v-if="loading" aria-busy="true" style="min-height: 320px" />
  <NewProjectForm v-else :default-template="defaultTemplate" :show-gallery="showGallery" />
</template>
```

src/components/NewProjectForm.vue

```html
<script setup lang="ts">
import { ref } from 'vue';
import { amplitude } from '../lib/amplitude';
import { TEMPLATES } from '../lib/templates';

const props = defineProps<{ defaultTemplate: string; showGallery: boolean }>();

// Seeded once, when the form mounts.
const template = ref(props.defaultTemplate);

async function handleCreate() {
  // ... your create logic

  amplitude.track('Project Created', { template: template.value });
}
</script>

<template>
  <div>
    <fieldset v-if="showGallery">
      <legend>Start from a template</legend>
      <label v-for="t in TEMPLATES" :key="t">
        <input v-model="template" type="radio" name="template" :value="t" />
        {{ t }}
      </label>
    </fieldset>
    <select v-else v-model="template">
      <option v-for="t in TEMPLATES" :key="t" :value="t">{{ t }}</option>
    </select>
    <button @click="handleCreate">Create project</button>
  </div>
</template>
```

**Why.** `ref(props.defaultTemplate)` copies the prop once, when the form's setup runs; it doesn't follow later prop changes. If the form mounted before the variant arrived, `template` would lock to `'blank'` and the payload would change nothing for that visit. The `v-if` on `loading` mounts `NewProjectForm` only after the first fetch settles, so its setup already sees the payload. The validation follows the docs' advice to keep sensible defaults: if someone edits the payload in Amplitude and drops a field, or misspells a template, users get the current experience instead of a broken form.

`useFlag` reads in `onMounted` rather than in setup, and its watcher skips any fetch that settles before mount, so the exposure fires only for a view that actually rendered. Composables that register lifecycle hooks have to be called synchronously inside `<script setup>`, not from an event handler or after an `await`.

The loading block reserves the form's height so the page doesn't jump when the variant arrives. It's still a short wait on the first visit, capped by the 3-second `fetchTimeoutMillis`.

Don't call `useFlag('new-project-flow')` in `App.vue`, in a layout component, or in a composable every view uses. Each of those tracks an exposure for users who never opened `/projects/new`, which dilutes the experiment. If the flag guards something that mounts before it's visible, such as a closed modal, set `automaticExposureTracking: false` in the `experiment` block and call `amplitude.experiment()?.exposure('new-project-flow')` when it opens.

## 5. Refetch on sign-in, clear on sign-out

Remote evaluation assigns variants to a user, so when the user changes, the variants can too. This `useAmplitudeIdentity` replaces the one from the Vue product analytics page. It sets the user ID and `plan` through `identifyUser`, which also fetches again and passes `plan` with the request, so a targeting rule on it applies right away.

src/composables/useAmplitudeIdentity.ts

```typescript
import { watch, type Ref } from 'vue';
import { identifyUser } from '../lib/amplitude';

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

export function useAmplitudeIdentity(user: Ref<User>) {
  watch(
    () => [user.value?.id, user.value?.plan] as const,
    ([id, plan]) => {
      // Signed out or not signed in yet: keep the same device ID.
      // Reset only from your sign-out handler.
      if (!id) return;

      // Amplitude drops IDs shorter than 5 characters. Use the same
      // string your backend sends as user_id.
      identifyUser(`user-${id}`, { plan: plan ?? 'unknown' });
    },
    { immediate: true },
  );
}
```

Reset from your sign-out handler instead:

src/auth/signOut.ts

```typescript
import { resetUser } from '../lib/amplitude';

export async function signOut() {
  // ... your sign-out logic

  // Clears the user ID, issues a new device ID, drops the previous
  // user's variants, and fetches again for the next person.
  resetUser();
}
```

`src/App.vue` doesn't change. It still calls `useAmplitudeIdentity(user)` once, where your auth state lives:

src/App.vue

```html
<script setup lang="ts">
import { useAuth } from './auth/useAuth'; // your auth composable
import { useAmplitudeIdentity } from './composables/useAmplitudeIdentity';

const { user } = useAuth();
useAmplitudeIdentity(user);
</script>

<template>
  <RouterView />
</template>
```

**Why.** A `null` user is also the state of every anonymous visitor on every page load, and `reset()` issues a new device ID each time, so a watcher that resets on `null` would split one visitor into many. Experiment buckets anonymous users by device ID, so a visitor who isn't signed in would land in a fresh bucket on each visit and could see the gallery one day and the dropdown the next. Resetting only from the sign-out handler keeps anonymous visitors on one device ID. Passing `plan` to `fetch()` matters for the same moment: targeting on user properties stored in Amplitude can lag by up to an hour, while properties sent with the request apply to that evaluation.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing flags and events before it writes code, so it reads `new-project-flow` instead of inventing `newProjectFlow`. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## Verify it worked

Run `npm run dev`, sign in as a test user, open `/projects/new`, then check in this order:

1. **Network tab.** Filter for `lab.amplitude.com`. You should see the variant request to `api.lab.amplitude.com` as the page loads, and another after sign-in; the response includes `new-project-flow`. Then filter for `api2.amplitude.com` and look for a `POST` to `/2/httpapi` returning `200`.
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. Within ten seconds to a minute, the event stream should show `[Experiment] Exposure` with `[Experiment] Flag Key` set to `new-project-flow` and `[Experiment] Variant` set to `on`, and usually an `[Experiment] Assignment` event from the remote fetch (Amplitude tracks one for fetches that miss its CDN cache). Amplitude renames `$exposure` to `[Experiment] Exposure` on ingestion. Create a project and `Project Created` appears with `template: kanban`. If the variant isn't `on`, your test user fell outside the 50% rollout; raise it to 100% while you verify.
4. **Console.** Add `logLevel: amplitude.Types.LogLevel.Debug` to the `analytics` block in development and reload. The console shows a `$exposure` event with `flag_key` `new-project-flow` and `variant` `on` after the page renders.

Signing in while `/projects/new` is open triggers a second fetch, and `useFlag` reads the variant again when it settles, so you can see two exposures for that visit. Opening the view after sign-in sends one.

## Troubleshooting

### The variant is always empty, for every user

The flag isn't reaching this deployment. Confirm the flag is Active, the `web` deployment is selected on the flag, and the deployment lives in the same project as your API key. A server deployment key in `VITE_AMPLITUDE_DEPLOYMENT_KEY` also fails here; use the key that starts with `client-`.

### No request to api.lab.amplitude.com

`initAll` never ran, so the warning from step 3 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 values are inlined at build time; set them in your build environment and rebuild.

### The variant is `on` but the form still preselects blank

The payload didn't pass validation, or the form mounted before the variant arrived. Check the payload in Amplitude for a template name that isn't in `TEMPLATES`, and keep `NewProjectForm` behind the `v-if="loading"` check so its `ref(props.defaultTemplate)` sees the payload when it first runs.

### The view never leaves the loading state

`useFlag` was called outside a component's setup, so `onMounted` and `watch` never attached; Vue logs a warning about no active component instance. Call it at the top level of `<script setup>`, before any `await`.

### Anonymous visitors switch variants between visits

Something calls `reset()` while the user is signed out, which issues a new device ID. Remove any `reset()` branch from a `useAmplitudeIdentity` you copied earlier and reset only in `signOut()`. Private windows and cleared cookies also produce a new device ID, and with it a new bucket.

### The exposure lands on an anonymous profile, not the test user

`useAmplitudeIdentity` isn't called, or it receives `user.value` instead of the reactive `user` ref, so the watcher never sees sign-in. It also fails when it passes a raw ID instead of the `user-` prefixed one; the ID your backend sends and the one you search for have to be the same string.

### A targeting rule on `plan` doesn't apply right after sign-in

The rule reads a property Experiment hasn't seen yet. Keep `plan` in the `identifyUser` call so it goes out with the fetch request; targeting on user properties stored in Amplitude can lag by up to an hour.

### Requests go out but nothing shows in the project

EU projects need `serverZone: 'EU'` at the top level of the `initAll` options, next to `analytics` and `experiment`, with keys from the EU project.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 10 files, plus your unchanged router and auth composable.

11 files, 221 lines

```typescript
// -- .env.local --
VITE_AMPLITUDE_API_KEY=your-project-api-key
VITE_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key

// -- src/lib/amplitude.ts --
import { readonly, ref } from 'vue';
import * as amplitude from '@amplitude/unified';

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

type FetchUser = { user_properties?: Record<string, string | number | boolean> };

const version = ref(0);
export const flagsVersion = readonly(version);

let fetchGeneration = 0;

const ready: Promise<unknown> = apiKey
  ? Promise.resolve(
      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,
        },
        experiment: {
          deploymentKey: import.meta.env.VITE_AMPLITUDE_DEPLOYMENT_KEY,
          fetchTimeoutMillis: 3000,
        },
      }),
    )
  : Promise.resolve();

if (!apiKey) {
  console.warn('VITE_AMPLITUDE_API_KEY is not set; Amplitude is off.');
}

async function refetchFlags(user?: FetchUser) {
  const generation = ++fetchGeneration;
  try {
    await ready;
    await amplitude.experiment()?.fetch(user);
  } finally {
    if (generation === fetchGeneration) version.value += 1;
  }

void refetchFlags();

export function identifyUser(userId: string, userProperties: Record<string, string>) {
  if (!apiKey) return;
  amplitude.setUserId(userId);
  const identify = new amplitude.Identify();
  Object.entries(userProperties).forEach(([key, value]) => identify.set(key, value));
  amplitude.identify(identify);
  void refetchFlags({ user_properties: userProperties });
}

export function resetUser() {
  if (!apiKey) return;
  amplitude.reset();
  amplitude.experiment()?.clear();
  void refetchFlags();
}

export { amplitude };

// -- src/lib/templates.ts --
export const TEMPLATES: string[] = ['blank', 'kanban', 'roadmap'];

// -- 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/useFlag.ts --
import { computed, onMounted, shallowRef, watch } from 'vue';
import { amplitude, flagsVersion } from '../lib/amplitude';

type FlagState = { value: string | null; payload: unknown };

export function useFlag(flagKey: string) {
  const flag = shallowRef<FlagState | undefined>(undefined);

  function read() {
    if (flagsVersion.value === 0) return;
    const variant = amplitude.experiment()?.variant(flagKey);
    flag.value = { value: variant?.value ?? null, payload: variant?.payload };
  }

  let mounted = false;
  onMounted(() => {
    mounted = true;
    read();
  });
  watch(flagsVersion, () => {
    if (mounted) read();
  });

  return {
    loading: computed(() => flag.value === undefined),
    value: computed(() => flag.value?.value ?? null),
    payload: computed(() => flag.value?.payload),
  };
}

// -- src/views/NewProject.vue --
<script setup lang="ts">
import { computed } from 'vue';
import { useFlag } from '../composables/useFlag';
import { TEMPLATES } from '../lib/templates';
import NewProjectForm from '../components/NewProjectForm.vue';

type NewProjectPayload = {
  defaultTemplate?: unknown;
  showTemplateGallery?: unknown;
};

const { loading, value, payload } = useFlag('new-project-flow');

const config = computed(
  () => (value.value === 'on' ? payload.value : undefined) as NewProjectPayload | undefined,
);

const defaultTemplate = computed(() => {
  const candidate = config.value?.defaultTemplate;
  return typeof candidate === 'string' && TEMPLATES.includes(candidate) ? candidate : 'blank';
});

const showGallery = computed(() => config.value?.showTemplateGallery === true);
</script>

<template>
  <div v-if="loading" aria-busy="true" style="min-height: 320px" />
  <NewProjectForm v-else :default-template="defaultTemplate" :show-gallery="showGallery" />
</template>

// -- src/components/NewProjectForm.vue --
<script setup lang="ts">
import { ref } from 'vue';
import { amplitude } from '../lib/amplitude';
import { TEMPLATES } from '../lib/templates';

const props = defineProps<{ defaultTemplate: string; showGallery: boolean }>();

const template = ref(props.defaultTemplate);

async function handleCreate() {
  amplitude.track('Project Created', { template: template.value });
}
</script>

<template>
  <div>
    <fieldset v-if="showGallery">
      <legend>Start from a template</legend>
      <label v-for="t in TEMPLATES" :key="t">
        <input v-model="template" type="radio" name="template" :value="t" />
        {{ t }}
      </label>
    </fieldset>
    <select v-else v-model="template">
      <option v-for="t in TEMPLATES" :key="t" :value="t">{{ t }}</option>
    </select>
    <button @click="handleCreate">Create project</button>
  </div>
</template>

// -- src/composables/useAmplitudeIdentity.ts --
import { watch, type Ref } from 'vue';
import { identifyUser } from '../lib/amplitude';

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;
      identifyUser(`user-${id}`, { plan: plan ?? 'unknown' });
    },
    { immediate: true },
  );
}

// -- src/auth/signOut.ts --
import { resetUser } from '../lib/amplitude';

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

// -- src/App.vue --
<script setup lang="ts">
import { useAuth } from './auth/useAuth';
import { useAmplitudeIdentity } from './composables/useAmplitudeIdentity';

const { user } = useAuth();
useAmplitudeIdentity(user);
</script>

<template>
  <RouterView />
</template>

// -- src/router/index.ts, src/auth/useAuth.ts --
// Your existing router (with the /projects/new route) and auth composable.
// 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

```text
Add Amplitude feature flags to this Vue and Vite app by following
https://amplitude.com/build/vue-feature-flags exactly. Use
@amplitude/unified and call initAll once at module scope in
src/lib/amplitude.ts, with experiment.deploymentKey read from
VITE_AMPLITUDE_DEPLOYMENT_KEY (a client deployment key, never a server
key), and start the first experiment()?.fetch() in that module. Export a
read-only flagsVersion ref that goes up when each fetch settles. Import
the module first in src/main.ts, before createApp. Add a useFlag
composable that calls variant() in onMounted and again when flagsVersion
changes, and call it only in the view that renders the flag, never in
App.vue or a layout, because variant() tracks an exposure. Mount anything
that seeds a ref from the payload behind v-if on loading, and validate the
payload with defaults. Set a "user-" prefixed user ID in
useAmplitudeIdentity and pass user properties to fetch(). Call reset() and
experiment()?.clear() only from the sign-out handler, never when the user
is null. Keep the session replay sample rate at 1 in development and 0.01
in production, keep any debugMode and privacyConfig already in
sessionReplay, and tell me how to turn replay off. Don't hardcode keys and
don't invent flag keys. Report every file you changed.
```

## What this unlocks

- [Feature Experiment](https://amplitude.com/amplitude-experiment) can run `new-project-flow` as an A/B test with `Project Created` as the primary metric, using the exposures this page sends.
- [Session replay](https://amplitude.com/session-replay), already on from step 3, lets you filter replays to users with the `[Experiment] new-project-flow` user property and watch the gallery in use.
- [Heatmaps](https://amplitude.com/heatmaps) are built from those same session replays, so they show where people click on `/projects/new` in each variant.
- [Guides and surveys](https://amplitude.com/guides-and-surveys), bundled in the same package, can ask users in the `on` variant whether the gallery helped.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add feature flags and experiments to a Nuxt appEvaluate the flag during the server render so the first paint already has the variant.](/build/nuxt-feature-flags)[Web frameworksHow to add session replay to a Vue appTune replay sampling and masking so you can watch each variant of \`/projects/new\` in use.](/build/vue-session-replay)[Web frameworksHow to add Web Experiment to a Vue appTest copy and layout changes with the visual editor, no flag code required.](/build/vue-web-experiment)

Reference docs

[Experiment JavaScript SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-javascript)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Configure your experiment (deployments) →](https://amplitude.com/docs/feature-experiment/workflow/configure)[JSON payloads →](https://amplitude.com/docs/feature-experiment/json-payloads)[Experiment event tracking →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

Amplitude is the leading [all-in-one analytics platform](https://amplitude.com/) for building better products. Product and engineering teams use Amplitude to connect [product analytics](https://amplitude.com/amplitude-analytics) and [web analytics](https://amplitude.com/web-analytics) with [session replay](https://amplitude.com/session-replay), [heatmaps](https://amplitude.com/heatmaps), [experimentation](https://amplitude.com/amplitude-experiment), [feature flags](https://amplitude.com/feature-management), and [guides and surveys](https://amplitude.com/guides-and-surveys) in one unified workflow, with [AI agents](https://amplitude.com/ai-agents) working across all of it. Learn how Amplitude is building [self-improving products](https://amplitude.com/self-improving-products) with [Wave](https://amplitude.com/wave), helping teams turn product data into PRs.

For agents

[View raw .md](/build/vue-feature-flags.md)[llms.txt](/llms.txt)

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

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
