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

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

Create a flag with a JSON payload, read it in a Vite and React Router app, tie exposures to signed-in users, and keep anonymous visitors in one bucket.

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

---

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

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

- React 18+
- Vite
- React Router
- Unified SDK 1.x
- 15 min

Chris Tam · Last updated 7 October 2026

[Feature flags](https://amplitude.com/feature-management) let you ship a React 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 in a Vite and React Router app, and tie exposures to signed-in users. It builds on the [React product analytics page](/build/react-product-analytics).

## What you'll build

A React app where the `/projects/new` screen reads the `new-project-flow` flag, 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 screen renders. `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 Vite project with React 18 or later, TypeScript, and React Router (`react-router-dom`). `npm create vite@latest` with the React and TypeScript template is fine.
- 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? A free account is enough for this tutorial; nothing here needs a paid plan. [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.

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 Assignment panel, 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 travel in network responses, 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 React 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 only writes events, and a client deployment key is designed to be public. 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 [Add Web Experiment to a Next.js app](/build/nextjs-web-experiment).

## 3. Initialize once and fetch flags at startup

This file replaces `src/lib/amplitude.ts` from the React product analytics page. It initializes at module scope, starts the first flag fetch right away, and keeps a small version counter that components subscribe to, so they rerender when new variants arrive.

src/lib/amplitude.ts

```typescript
import * as amplitude from '@amplitude/unified';

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

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

let flagsVersion = 0;
let fetchGeneration = 0;
const listeners = new Set<() => void>();

function notifyFlagsChanged() {
  flagsVersion += 1;
  listeners.forEach((listener) => listener());
}

// 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,
          },
        experiment: {
          // Falls back to the project API key (the default deployment) 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) notifyFlagsChanged();
  }

// 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 function subscribeFlags(listener: () => void) {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

export function getFlagsVersion() {
  return flagsVersion;
}

export { amplitude };
```

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

src/main.tsx

```typescript
import './lib/amplitude';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);
```

**Why.** A Vite app has no server render, so the variant request can only start in the browser. Starting it at module scope, before `createRoot`, gives it the head start of React's first render instead of waiting for a component's effect. A module body runs once per page load, and Strict Mode doesn't replay it the way it replays effects, so there's no second `initAll` to guard against.

This file sets no `sessionReplay` option, so session replay doesn't record. If you kept `sessionReplay: { sampleRate: 1 }` from the product analytics page, every session is recorded; keep it if you want that, or set it to `0`. 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).

## 4. Read the variant where it renders

`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, after that component renders, and nowhere else.

src/hooks/useFlag.ts

```typescript
import { useEffect, useState, useSyncExternalStore } from 'react';
import { amplitude, getFlagsVersion, subscribeFlags } from '../lib/amplitude';

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

export function useFlag(flagKey: string) {
  const version = useSyncExternalStore(subscribeFlags, getFlagsVersion);
  const [flag, setFlag] = useState<FlagState | undefined>(undefined);

  useEffect(() => {
    if (version === 0) return; // the first fetch hasn't settled yet
    // Reading the variant in this component's effect is what ties the
    // exposure to someone who actually reached this screen.
    const variant = amplitude.experiment()?.variant(flagKey);
    setFlag({ value: variant?.value ?? null, payload: variant?.payload });
  }, [flagKey, version]);

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

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

src/pages/NewProject.tsx

```typescript
import { useState } from 'react';
import { amplitude } from '../lib/amplitude';
import { useFlag } from '../hooks/useFlag';

const TEMPLATES = ['blank', 'kanban', 'roadmap'];

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

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

  if (loading) {
    return <div aria-busy="true" style={{ minHeight: 320 }} />;
  }

  // Only the "on" variant carries the new settings. Validate the payload
  // and keep the current defaults for anything missing or unexpected.
  const config = (value === 'on' ? payload : undefined) as NewProjectPayload | undefined;
  const defaultTemplate =
    typeof config?.defaultTemplate === 'string' && TEMPLATES.includes(config.defaultTemplate)
      ? config.defaultTemplate
      : 'blank';
  const showGallery = config?.showTemplateGallery === true;

  return <NewProjectForm defaultTemplate={defaultTemplate} showGallery={showGallery} />;
}

function NewProjectForm({
  defaultTemplate,
  showGallery,
}: {
  defaultTemplate: string;
  showGallery: boolean;
}) {
  const [template, setTemplate] = useState(defaultTemplate);

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

    amplitude.track('Project Created', { template });
  }

  return (
    <div>
      {showGallery ? (
        <fieldset>
          <legend>Start from a template</legend>
          {TEMPLATES.map((t) => (
            <label key={t}>
              <input
                type="radio"
                name="template"
                value={t}
                checked={template === t}
                onChange={() => setTemplate(t)}
              />
              {t}
            </label>
          ))}
        </fieldset>
      ) : (
        <select value={template} onChange={(e) => setTemplate(e.target.value)}>
          {TEMPLATES.map((t) => (
            <option key={t} value={t}>
              {t}
            </option>
          ))}
        </select>
      )}
      <button onClick={handleCreate}>Create project</button>
    </div>
  );
}
```

**Why.** `useState(defaultTemplate)` uses its argument once, on the first render. If the form rendered before the variant arrived, `template` would lock to `'blank'` and the payload would change nothing for that visit. Mounting `NewProjectForm` only after `loading` is false means its first render already has 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.

The loading placeholder 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.tsx`, in a layout route, or in a hook every page 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 `IdentifyUser` replaces the one from the React product analytics page. It sets the user ID and `plan`, then fetches again, passing `plan` with the request so a targeting rule on it applies right away.

src/analytics/IdentifyUser.tsx

```typescript
import { useEffect } from 'react';
import { identifyUser } from '../lib/amplitude';

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

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    if (!user) return; // anonymous: keep the same device ID
    // Amplitude drops IDs shorter than 5 characters. Use the same
    // string your backend sends as user_id.
    identifyUser(`user-${user.id}`, { plan: user.plan });
  }, [user?.id, user?.plan]);

  return null;
}
```

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

  resetUser();
}
```

Mount `IdentifyUser` once in `App.tsx`, where your auth state lives:

src/App.tsx

```typescript
import { Routes, Route } from 'react-router-dom';
import { IdentifyUser } from './analytics/IdentifyUser';
import { useAuth } from './auth/useAuth';
import { Home } from './pages/Home';
import { NewProject } from './pages/NewProject';

export function App() {
  const { user } = useAuth(); // your auth hook

  return (
    <>
      <IdentifyUser user={user} />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/projects/new" element={<NewProject />} />
      </Routes>
    </>
  );
}
```

**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 an `IdentifyUser` 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: user properties that reach Experiment through analytics can lag behind, 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`. To read the events themselves, add `logLevel: amplitude.Types.LogLevel.Debug` to the `analytics` block in development and watch the console for a `$exposure` event with `flag_key` and `variant`.
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.

In development, React Strict Mode runs effects twice, so you may see two exposures per visit to `/projects/new`. A production build 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 client key.

### 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 `loading` check so its `useState` sees the payload on its first render.

### Anonymous visitors switch variants between visits

Something calls `reset()` while the user is signed out, which issues a new device ID. Remove the `reset()` branch from any `IdentifyUser` 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

`IdentifyUser` isn't mounted, or 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. 9 files.

9 files, 243 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 * as amplitude from '@amplitude/unified';

const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY;

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

let flagsVersion = 0;
let fetchGeneration = 0;
const listeners = new Set<() => void>();

function notifyFlagsChanged() {
  flagsVersion += 1;
  listeners.forEach((listener) => listener());
}

const ready: Promise<unknown> = apiKey
  ? Promise.resolve(
      amplitude.initAll(apiKey, {
        analytics: {
          autocapture: {
            attribution: true,
            pageViews: true,
            sessions: true,
            formInteractions: true,
            fileDownloads: true,
            elementInteractions: true,
          },
        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) notifyFlagsChanged();
  }

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 function subscribeFlags(listener: () => void) {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

export function getFlagsVersion() {
  return flagsVersion;
}

export { amplitude };

// -- src/main.tsx --
import './lib/amplitude';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);

// -- src/hooks/useFlag.ts --
import { useEffect, useState, useSyncExternalStore } from 'react';
import { amplitude, getFlagsVersion, subscribeFlags } from '../lib/amplitude';

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

export function useFlag(flagKey: string) {
  const version = useSyncExternalStore(subscribeFlags, getFlagsVersion);
  const [flag, setFlag] = useState<FlagState | undefined>(undefined);

  useEffect(() => {
    if (version === 0) return;
    const variant = amplitude.experiment()?.variant(flagKey);
    setFlag({ value: variant?.value ?? null, payload: variant?.payload });
  }, [flagKey, version]);

  return {
    loading: flag === undefined,
    value: flag?.value ?? null,
    payload: flag?.payload,
  };
}

// -- src/pages/NewProject.tsx --
import { useState } from 'react';
import { amplitude } from '../lib/amplitude';
import { useFlag } from '../hooks/useFlag';

const TEMPLATES = ['blank', 'kanban', 'roadmap'];

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

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

  if (loading) {
    return <div aria-busy="true" style={{ minHeight: 320 }} />;
  }

  const config = (value === 'on' ? payload : undefined) as NewProjectPayload | undefined;
  const defaultTemplate =
    typeof config?.defaultTemplate === 'string' && TEMPLATES.includes(config.defaultTemplate)
      ? config.defaultTemplate
      : 'blank';
  const showGallery = config?.showTemplateGallery === true;

  return <NewProjectForm defaultTemplate={defaultTemplate} showGallery={showGallery} />;
}

function NewProjectForm({
  defaultTemplate,
  showGallery,
}: {
  defaultTemplate: string;
  showGallery: boolean;
}) {
  const [template, setTemplate] = useState(defaultTemplate);

  async function handleCreate() {
    amplitude.track('Project Created', { template });
  }

  return (
    <div>
      {showGallery ? (
        <fieldset>
          <legend>Start from a template</legend>
          {TEMPLATES.map((t) => (
            <label key={t}>
              <input
                type="radio"
                name="template"
                value={t}
                checked={template === t}
                onChange={() => setTemplate(t)}
              />
              {t}
            </label>
          ))}
        </fieldset>
      ) : (
        <select value={template} onChange={(e) => setTemplate(e.target.value)}>
          {TEMPLATES.map((t) => (
            <option key={t} value={t}>
              {t}
            </option>
          ))}
        </select>
      )}
      <button onClick={handleCreate}>Create project</button>
    </div>
  );
}

// -- src/analytics/IdentifyUser.tsx --
import { useEffect } from 'react';
import { identifyUser } from '../lib/amplitude';

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

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    if (!user) return;
    identifyUser(`user-${user.id}`, { plan: user.plan });
  }, [user?.id, user?.plan]);

  return null;
}

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

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

// -- src/App.tsx --
import { Routes, Route } from 'react-router-dom';
import { IdentifyUser } from './analytics/IdentifyUser';
import { useAuth } from './auth/useAuth';
import { Home } from './pages/Home';
import { NewProject } from './pages/NewProject';

export function App() {
  const { user } = useAuth();
  return (
    <>
      <IdentifyUser user={user} />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/projects/new" element={<NewProject />} />
      </Routes>
    </>
  );
}

// -- src/pages/Home.tsx, src/auth/useAuth.ts --
// Your existing page and auth hook. Nothing in them changes.
```

## Hand it to your agent

Prefer to have Cursor or Claude Code 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 Vite + React Router app by following
https://amplitude.com/build/react-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. Import it
first in src/main.tsx. Read the new-project-flow flag only inside the
component that renders it, in an effect, never in App.tsx or a layout
route, because variant() tracks an exposure. Wait for the first fetch
before mounting anything that seeds useState from the payload, and
validate the payload with defaults. Set a "user-" prefixed user ID on
sign-in and pass user properties to fetch(). Call reset() and
experiment()?.clear() only from the sign-out handler, never when the user
is null. Don't hardcode keys, don't invent flag keys, and don't turn on
session replay unless it's already on. 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), once you set a sample rate, 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 session replays, so with replay on 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 frameworksAdd product analytics to a React appTrack \`Project Created\` and identify users, the events this flag is measured against.](/build/react-product-analytics)[Web frameworksAdd session replay to a React appRecord sessions so you can watch each variant of \`/projects/new\` in use.](/build/react-session-replay)[Web frameworksAdd analytics to your Node.js backendSend server-side events with the same user ID, so they count toward the same experiment.](/build/nodejs-product-analytics)

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/react-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)
