---
title: "How to add feature flags and experiments to an Astro site"
description: "Fetch Amplitude feature flags in Astro's browser code, evaluate them in middleware for pages rendered on demand, and track exposures once."
canonical: "https://amplitude.com/en-us/build/astro-feature-flags"
---

# How to add feature flags and experiments to an Astro site

Fetch Amplitude feature flags in Astro's browser code, evaluate them in middleware for pages rendered on demand, and track exposures once.

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

---

# How to add feature flags and experiments to an Astro site

Read Amplitude feature flags in the browser on any Astro page, evaluate them in middleware for pages rendered on demand, and send each exposure once. About 30 minutes, all copy-paste.

- Astro 6+
- Static or on-demand
- Unified SDK 1.x
- Experiment Node SDK 1.x
- 30 min

Chris Tam · Last updated 19 October 2026

Most Astro pages are HTML built once and shared by every visitor, so a [feature flag](https://amplitude.com/feature-management) on them resolves in the browser. Pages rendered on demand can resolve it on the server, so the right variant is in the first HTML.

In this tutorial, you'll fetch flags in the browser after identity, evaluate them in middleware for pages rendered on demand, and send each exposure once. It builds on [the Astro product analytics page](/build/astro-product-analytics).

## What you'll build

A `/projects/new` page whose `new-project-flow` flag is evaluated in middleware, so visitors in `on` get a template gallery straight in the server HTML, with no swap after load. The browser sends one `[Experiment] Exposure` for it, bucketed on the same device ID as the server, even on a first visit. After someone creates a project, a browser-fetched `project-checklist` flag decides whether they see a checklist, on prerendered and on-demand pages alike, and `Project Created` with its `template` property is the metric.

## Prerequisites

- The site from [How to add product analytics to an Astro site](/build/astro-product-analytics), including `src/scripts/amplitude.ts`, `Amplitude.astro` with its identity element, `src/middleware.ts`, `src/env.d.ts`, `getUser` in `src/lib/auth.ts`, and `SignOutButton.astro`.
- Browser flags (steps 2 and 3) work on every page, prerendered or rendered on demand, with or without `<ClientRouter />`.
- Server flags (step 4) need an adapter that runs a long-lived Node.js server, such as `@astrojs/node`, and a page with `export const prerender = false`. A function that cold-starts per request downloads the flag rules every time and often renders defaults.
- Access to Experiment in the same Amplitude project your events go to, so you can create deployments and flags.

No Amplitude account yet? Feature flags and local evaluation are available on all plans, so a free account covers this tutorial. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Create the deployments and flags

A deployment serves a group of flags through its own key, separate from your project API key. A client-side deployment key is publicly viewable and belongs in browser code; a server-side deployment key stays secret. This page evaluates flags in both places, so it uses one of each ([deployment docs](https://amplitude.com/docs/feature-experiment/data-model#deployments)).

Create the two deployments:

1. Go to Experiment > Deployments, then click Create Deployment.
2. Choose the Amplitude project your analytics events go to, or exposures won't line up with users.
3. Name it `astro-server`, choose Server-side as the type, click Create a Deployment, and copy the key.
4. Repeat for a second deployment named `web`, with Client-side as the type.

Then create the flag the server renders:

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 key `new-project-flow`, which your code reads.
3. Set Evaluation Mode to Local, set the bucketing unit to the user, and click Create.
4. Choose the `astro-server` deployment from the Deployment drop-down.
5. Open the default `on` variant that new flags come with. In its Payload field, paste the JSON below, then click Apply.
6. In the All Other Users section, set bucketed by to Device ID, then set the rollout to 50% so you can see both branches. Save, then set the flag to Active.

Variant payload

```json
// Variant payload (JSON)
{
  "defaultTemplate": "kanban",
  "showTemplateGallery": true
}
```

Then create the flag the browser fetches: Project checklist (key `project-checklist`), Remote evaluation, on the `web` deployment, with the default `on` variant, no payload, a 50% rollout, and Active.

**Why.** Local evaluation downloads the flag rules once and evaluates them in your server process, with no network call per request. It can't resolve the Amplitude ID, which is why local flags bucket by device ID, and why steps 2 and 4 hand one device ID between server and browser: if they disagree on it, they disagree on the variant. The checklist flag stays remote because it shows only after a click, which leaves the browser time to fetch it, and remote evaluation works on prerendered pages that never reach your server.

Don't name a variant `off`; Experiment reserves it for users outside the rollout. If `new-project-flow` already exists as a remote flag, create a new local flag instead: changing the evaluation mode changes bucketing, so deactivate a flag before you migrate it.

## 2. Add the keys and the experiment block

Server-side flags use the [Experiment Node.js SDK](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js), separate from `@amplitude/unified`, which already bundles Feature Experiment for the browser.

Terminal

```sh
npm install @amplitude/experiment-node-server
```

Add both deployment keys:

.env

```sh
# Keep lines other Astro pages added here: PUBLIC_AMPLITUDE_API_KEY
# (/build/astro-product-analytics).
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
PUBLIC_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key
AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key
```

Astro inlines `PUBLIC_` variables into the browser bundle at build time, so never give the server key that prefix. Step 4 reads it at runtime with `getSecret()` from `astro:env/server`. The dev server also reads it from `.env`; a built server reads only its own environment, so set it there. Set the `PUBLIC_` keys where you build and `AMPLITUDE_SERVER_DEPLOYMENT_KEY` where the server runs, and keep `.env` out of version control.

Server and browser code share a cookie reader and the name of the cookie step 4 uses to hand a device ID to the browser:

src/lib/amplitude-cookies.ts

```typescript
// Set by the server when it evaluates flags for a visitor who has no
// AMP_ cookie yet. Not the SDK's own cookie.
export const DEVICE_ID_HANDOFF_COOKIE = "amp_server_device_id";

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export function isDeviceId(value: string | undefined): value is string {
  return value !== undefined && UUID.test(value);
}

// Reads one cookie from document.cookie. A malformed cookie returns undefined.
export function readCookie(cookies: string, name: string): string | undefined {
  const prefix = `${name}=`;
  const match = cookies.split(/;\s*/).find((c) => c.startsWith(prefix));
  if (!match) return undefined;
  try {
    return decodeURIComponent(match.slice(prefix.length));
  } catch {
    return undefined;
  }
```

Then update `src/scripts/amplitude.ts`, keeping the lines its first comment names. What changed: it passes the server's handoff device ID as `analytics.deviceId` when the browser has no `AMP_` cookie yet, and it adds the `experiment` block.

src/scripts/amplitude.ts

```typescript
// Keep lines other Astro pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/astro-web-analytics); debugMode and privacyConfig
// (/build/astro-session-replay); the engagement block with renderCssInDom
// (/build/astro-guides-surveys); the optional webExperiment plugin lines below
// the amplitudeReady export (/build/astro-web-experiment). Import this module
// only from <script> tags, never from component frontmatter.
import * as amplitude from "@amplitude/unified";
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId, readCookie } from "../lib/amplitude-cookies";

export * from "@amplitude/unified";

const apiKey = import.meta.env.PUBLIC_AMPLITUDE_API_KEY;

if (!apiKey) {
  console.warn("PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.");
}

// The SDK names its cookie AMP_ plus the first 10 characters of the key.
// No AMP_ cookie and a handoff from step 4 means a page rendered on demand
// already bucketed this visitor: adopt that device ID, on that load only.
const hasAmpCookie =
  !!apiKey && readCookie(document.cookie, `AMP_${apiKey.slice(0, 10)}`) !== undefined;
const handoff = readCookie(document.cookie, DEVICE_ID_HANDOFF_COOKIE);
const serverDeviceId = !hasAmpCookie && isDeviceId(handoff) ? handoff : undefined;

// Resolves once initAll finishes. Feature flags and guides and surveys
// wait on it.
export const amplitudeReady: Promise<void> = apiKey
  ? amplitude
      .initAll(apiKey, {
        analytics: {
          autocapture: {
            attribution: true,
            pageViews: true,
            sessions: true,
            formInteractions: true,
            fileDownloads: true,
            elementInteractions: true,
          },
          ...(serverDeviceId ? { deviceId: serverDeviceId } : {}),
        },
        sessionReplay: {
          // Every session while you build; about 1% in production.
          sampleRate: import.meta.env.DEV ? 1 : 0.01,
          // Ignores the focus check so replays record with DevTools open. Setup only.
          debugMode: import.meta.env.DEV,
          // Selectors for the data attributes in steps 3 and 4.
          privacyConfig: {
            maskSelector: ["[data-amp-mask]"],
            blockSelector: ["[data-amp-block]"],
            unmaskSelector: ["[data-amp-unmask]"],
          },
        experiment: {
          // The client deployment key, for the browser flags in step 3.
          deploymentKey: import.meta.env.PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
          // Default is 10 seconds. Fall back to the current UI sooner.
          fetchTimeoutMillis: 3000,
        },
      })
      .catch((error) => {
        console.warn("Amplitude failed to initialize", error);
      })
  : Promise.resolve();
```

**Why.** `deviceId` is passed only when the browser has no `AMP_` cookie, so a stale handoff never replaces a device ID the browser already has; from then on the SDK writes its own cookie and step 4 reads it. On a site with no page rendered on demand, the handoff cookie never exists and this code does nothing. The `sessionReplay` block is unchanged from [the session replay page](/build/astro-session-replay); set `sampleRate` to `0` to turn session replay off.

## 3. Fetch browser flags after identity, clear them on sign-out

`experiment()` returns `undefined` until `initAll` settles, and a fetch has to carry the signed-in user, so this module waits on `amplitudeReady`, reads the user, and fetches once per identity:

src/scripts/flags.ts

```typescript
import * as amplitude from "./amplitude";

export type BrowserVariant = { value?: string; payload?: unknown };

let fetchedFor: string | undefined;
let fetching: Promise<boolean> = Promise.resolve(false);
const read = new Map<string, BrowserVariant>();

// Fetches once per identity: once per page load without <ClientRouter />,
// once per visit with it, and again after sign-in or sign-out.
export async function browserFlagsReady(): Promise<boolean> {
  await amplitude.amplitudeReady;
  const client = amplitude.experiment();
  if (!client) return false;

  // The identity element from the product analytics page carries the
  // signed-in user on pages rendered on demand; elsewhere, the SDK's ID.
  const identity = document.querySelector<HTMLElement>("amplitude-identity");
  const userId = identity?.dataset.userId ?? amplitude.getUserId() ?? undefined;
  const plan = identity?.dataset.plan;

  const key = userId ?? "anonymous";
  if (key !== fetchedFor) {
    fetchedFor = key;
    read.clear();
    fetching = client
      .fetch({
        ...(userId ? { user_id: userId } : {}),
        // Pass plan with the request, so targeting sees it on this evaluation.
        ...(plan ? { user_properties: { plan } } : {}),
      })
      // On failure or timeout the SDK returns and retries in the background.
      .then(
        () => true,
      );
  }
  return fetching;
}

// variant() tracks [Experiment] Exposure, so call this only where the
// variant shows. Each flag is read once per fetch.
export async function browserVariant(flagKey: string): Promise<BrowserVariant | undefined> {
  if (!(await browserFlagsReady())) return undefined;
  const cached = read.get(flagKey);
  if (cached) return cached;
  const variant = amplitude.experiment()?.variant(flagKey);
  const result = { value: variant?.value ?? undefined, payload: variant?.payload };
  read.set(flagKey, result);
  return result;
}
```

Then clear the previous user's variants on sign-out. Add the `clear()` line to `SignOutButton.astro`, after `reset()`. Keep the `data-astro-reload` attribute the product analytics page puts on the form: it makes `<ClientRouter />` submit sign-out with a full page load, so the SDK starts fresh for the next person; without it, the signed-out flag fetch still carries the previous user's user properties.

src/components/SignOutButton.astro

```html
---
// Keep lines other Astro pages added here: the getEngagement import and
// getEngagement()?.gs.closeAll() before reset() (/build/astro-guides-surveys).
// Order: closeAll, reset, clear.
---

<sign-out-button>
  <form method="post" action="/logout" data-astro-reload>
    <button type="submit">Sign out</button>
  </form>
</sign-out-button>

<script>
  import * as amplitude from "../scripts/amplitude";

  class SignOutButton extends HTMLElement {
    connectedCallback() {
      this.querySelector("form")?.addEventListener("submit", () => {
        // Clears the user ID and issues a new device ID for the next person.
        amplitude.reset();
        // Drops the previous user's browser variants.
        amplitude.experiment()?.clear();
        try {
          sessionStorage.removeItem("amplitude-identity");
        } catch {}
      });
    }

  customElements.define("sign-out-button", SignOutButton);
</script>
```

**Why.** The docs recommend fetching again when the user signs in, and passing new user properties with the request, because a property Amplitude only just received can race the evaluation. The user comes from the identity element's data attributes rather than from `setUserId`, so the fetch never depends on which component script runs first.

Navigation decides how often this fetches. Without `<ClientRouter />`, every link is a full page load, the module starts fresh, and each page that shows a browser flag fetches once. With it, bundled scripts run once per visit, so the module remembers the identity it fetched for and skips the request on every navigation until someone signs in or out. The sign-out form reloads the page, so the next flag read fetches for the new anonymous device. Between fetches, the client keeps variants in local storage, its default source. `initialVariants` bootstraps the client with values rendered by the server; step 5 renders the server's variant straight into the HTML instead, so nothing here needs bootstrapping.

## 4. Evaluate on the server for pages rendered on demand

No adapter? Add only this `flags` field to the `Locals` in `src/env.d.ts`, so the page in step 5 type-checks without the server modules, then skip to step 5; the page there prerenders and keeps its defaults:

src/env.d.ts

```typescript
// src/env.d.ts (no adapter only, inside interface Locals)
flags: { deviceId: string; userId?: string; flags: Record<string, { value: string; payload?: unknown }> } | null;
```

This module owns the local evaluation client. Astro has no server-only file suffix, so import it only from middleware or page frontmatter, never from a `<script>`.

src/lib/experiment.ts

```typescript
// Server only: import from middleware or frontmatter, never from a <script>.
import { Experiment } from "@amplitude/experiment-node-server";
import { getSecret } from "astro:env/server";

type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
export type ServerVariants = ReturnType<LocalClient["evaluateV2"]>;
export type ExperimentUser = {
  user_id?: string;
  device_id?: string;
  user_properties?: Record<string, string>;
};

// The dev server re-runs modules when you edit them. Keeping the client on
// globalThis means one client per process, not one per edit.
const store = globalThis as typeof globalThis & {
  __amplitudeFlags?: { client: LocalClient; ready: Promise<void> };
};

// Creates the client once per process and starts downloading flag rules.
// The first evaluation calls it; nothing starts when the module loads.
export function startFlags(): Promise<void> {
  if (!store.__amplitudeFlags) {
    const key = getSecret("AMPLITUDE_SERVER_DEPLOYMENT_KEY");
    if (!key) return Promise.reject(new Error("AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set"));

    const client = Experiment.initializeLocal(key);
    // start() downloads the flag rules, then polls for updates.
    const ready = client.start().catch((error) => {
      // Stop polling for the dropped client; the next request retries.
      client.stop();
      if (store.__amplitudeFlags?.client === client) store.__amplitudeFlags = undefined;
      throw error;
    });
    store.__amplitudeFlags = { client, ready };
  }
  return store.__amplitudeFlags.ready;
}

// Evaluates the given flags for one user in memory. Returns {} on any
// failure, so pages render their defaults instead of erroring.
export async function evaluateFlags(
  user: ExperimentUser,
  flagKeys: string[],
): Promise<ServerVariants> {
  if (!user.user_id && !user.device_id) return {};

  let timer: ReturnType<typeof setTimeout> | undefined;
  try {
    const started = startFlags();
    const current = store.__amplitudeFlags?.client;
    // A cold start or outage renders defaults instead of holding the page.
    await Promise.race([
      started,
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => reject(new Error("flag rules not loaded within 1 s")), 1000);
      }),
    ]);
    return current ? current.evaluateV2(user, flagKeys) : {};
  } catch (error) {
    console.error("[flags] evaluation failed; rendering defaults.", error);
    return {};
  } finally {
    clearTimeout(timer);
  }
```

`loadFlags` finds the visitor's device ID, evaluates, and keeps only plain variant objects. The browser SDK stores its device ID in the `AMP_` cookie, and the Node.js SDK's `AmplitudeCookie` class parses it. On a first visit there's no cookie yet, so the server creates a device ID, evaluates with it, and hands it to the browser in its own short-lived cookie, which step 2 adopts.

src/lib/server-flags.ts

```typescript
// Server only: import from middleware or frontmatter, never from a <script>.
import { randomUUID } from "node:crypto";
import type { APIContext } from "astro";
import { AmplitudeCookie } from "@amplitude/experiment-node-server";
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from "./amplitude-cookies";
import { evaluateFlags } from "./experiment";
import type { User } from "./auth";

// Only flags a page renders on the server.
export const SERVER_FLAG_KEYS = ["new-project-flow"];

// Routes whose pages render those flags. Other requests, such as API
// endpoints, skip evaluation and get no handoff cookie.
export const SERVER_FLAG_ROUTES = ["/projects/new"];

export type FlagVariant = { value: string; payload?: unknown };
export type ServerFlags = {
  deviceId: string;
  userId?: string;
  flags: Record<string, FlagVariant>;
};

type Cookies = APIContext["cookies"];

function readDeviceId(cookies: Cookies): string | undefined {
  const apiKey = import.meta.env.PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) return undefined;
  // cookieName throws on a malformed key: render defaults, not an error.
  try {
    // `true` selects the Browser SDK 2 cookie format, which unified uses.
    const value = cookies.get(AmplitudeCookie.cookieName(apiKey, true))?.value;
    return value ? (AmplitudeCookie.parse(value, true).device_id ?? undefined) : undefined;
  } catch {
    return undefined;
  }

export async function loadFlags(
  cookies: Cookies,
  user: User | null,
  flagKeys: string[],
): Promise<ServerFlags> {
  // 1. The browser SDK's own cookie.
  // 2. An ID this server minted earlier that the browser hasn't adopted yet.
  // 3. A new ID, handed to the browser in a cookie.
  let deviceId = readDeviceId(cookies);
  if (!deviceId) {
    const handoff = cookies.get(DEVICE_ID_HANDOFF_COOKIE)?.value;
    if (isDeviceId(handoff)) deviceId = handoff;
  }
  if (!deviceId) {
    deviceId = randomUUID();
    // Not HttpOnly: src/scripts/amplitude.ts reads it.
    cookies.set(DEVICE_ID_HANDOFF_COOKIE, deviceId, { path: "/", maxAge: 3600, sameSite: "lax" });
  }

  // The same "user-" prefix the browser sets, so both sides send one ID.
  const userId = user ? `user-${user.id}` : undefined;

  const variants = await evaluateFlags(
    {
      device_id: deviceId,
      user_id: userId,
      // Local evaluation only sees the properties you pass in.
      ...(user ? { user_properties: { plan: user.plan ?? "unknown" } } : {}),
    },
    flagKeys,
  );

  // Plain objects only, and nothing for users outside the rollout.
  const flags: ServerFlags["flags"] = {};
  for (const [flagKey, variant] of Object.entries(variants)) {
    if (variant?.value && variant.value !== "off") {
      flags[flagKey] = { value: variant.value, payload: variant.payload };
    }

  return { deviceId, userId, flags };
}
```

Call it from middleware, next to the user lookup, and store the result on `locals`. In `src/middleware.ts`, add one import and the `flags` lines:

src/middleware.ts

```typescript
// Keep lines other Astro pages added here: the user lookup
// (/build/astro-product-analytics). If you already have middleware, combine both
// with sequence().
import { defineMiddleware } from "astro:middleware";
import { getUser } from "./lib/auth";
import { loadFlags, SERVER_FLAG_KEYS, SERVER_FLAG_ROUTES } from "./lib/server-flags";

export const onRequest = defineMiddleware(async (context, next) => {
  const user = context.isPrerendered ? null : await getUser(context.request);
  context.locals.user = user;

  // Prerendered HTML is built once and shared by every visitor, so it gets
  // no flags. Routes rendered on demand that show a server flag get this
  // visitor's variants.
  context.locals.flags =
    !context.isPrerendered &&
    context.request.method === "GET" &&
    SERVER_FLAG_ROUTES.includes(context.routePattern)
      ? await loadFlags(context.cookies, user, SERVER_FLAG_KEYS)
      : null;

  return next();
});
```

Type the new `locals` field next to `user`:

src/env.d.ts

```typescript
// Keep lines other Astro pages added here: the user field in Locals
// (/build/astro-product-analytics). Add the Locals fields to any declarations you
// already have.
declare namespace App {
  interface Locals {
    user: import("./lib/auth").User | null;
    flags: import("./lib/server-flags").ServerFlags | null;
  }
```

**Why.** Flag rules are the same for everyone and refresh every 30 seconds by default, so one client lives per process and each request only evaluates, in about 0.025 ms per the docs. `evaluateV2` reads rules that are already loaded, so every call waits for `start()`, but never more than a second. The client starts on the first evaluation, never at module load: `astro build` imports your middleware and runs it for every prerendered page, and a poller started there can keep the build from exiting. `context.isPrerendered` is true during that build, so nothing evaluates or sets cookies there. `SERVER_FLAG_ROUTES` limits evaluation to the pages that render a server flag, matched on `context.routePattern`, so API endpoints and other pages never pay for an evaluation or get a handoff cookie. Add a route there when another page reads `Astro.locals.flags`. The first request after a deploy may render defaults; later requests catch up, and a failed `start()` is retried on the next request. If your own server code calls `server.close()` on shutdown and waits for the event loop to drain, call `client.stop()` there too.

The [Node.js local evaluation page](/build/nodejs-local-evaluation) covers streaming updates, cohort targeting, and server-side exposure tracking, and the [Node.js feature flags page](/build/nodejs-feature-flags) covers remote evaluation on a server.

## 5. Render the variants and send one exposure

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

src/lib/templates.ts

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

The server's variant is already in the HTML, so the browser only has to send its exposure. `FlagExposure.astro` carries what the server evaluated and sends `$exposure` once the SDK is ready, only if the browser's device and user match:

src/components/FlagExposure.astro

```html
---
type Props = { flagKey: string; variant: string; deviceId: string; userId?: string };
const { flagKey, variant, deviceId, userId } = Astro.props;
---

<flag-exposure
  hidden
  data-flag-key={flagKey}
  data-variant={variant}
  data-device-id={deviceId}
  data-user-id={userId}
></flag-exposure>

<script>
  import * as amplitude from "../scripts/amplitude";

  // Once per page load without <ClientRouter />, once per visit with it.
  const sent = new Set<string>();

  class FlagExposure extends HTMLElement {
    async connectedCallback() {
      const { flagKey, variant, deviceId, userId } = this.dataset;
      if (!flagKey || !variant || !deviceId) return;

      const key = `${userId ?? ""}|${deviceId}|${flagKey}|${variant}`;
      if (sent.has(key)) return;
      sent.add(key);

      // getDeviceId() is undefined until initAll settles.
      await amplitude.amplitudeReady;
      const browserDeviceId = amplitude.getDeviceId();
      const browserUserId = amplitude.getUserId();
      // A variant evaluated for another device or user isn't this visitor's.
      const sameUser = !userId || !browserUserId || browserUserId === userId;
      if (browserDeviceId !== deviceId || !sameUser) {
        if (import.meta.env.DEV) {
          console.warn(
            `[flags] no exposure for ${flagKey}: the browser's device or user differs from what the server evaluated.`,
          );
        }
        return;
      }

      amplitude.track({
        event_type: "$exposure",
        event_properties: { flag_key: flagKey, variant },
        // The user the server evaluated, if the identity element hasn't set it yet.
        ...(userId && !browserUserId ? { user_id: userId } : {}),
      });
    }

  customElements.define("flag-exposure", FlagExposure);
</script>
```

The checklist reads the browser flag. It starts the fetch when it connects and reads the variant, which tracks the exposure, only when a project is created:

src/components/ProjectChecklist.astro

```html
<project-checklist hidden>
  <aside>
    <h2>Next steps</h2>
    <ul>
      <li>Invite your team</li>
      <li>Import your existing tasks</li>
    </ul>
  </aside>
</project-checklist>

<script>
  import { browserFlagsReady, browserVariant } from "../scripts/flags";

  class ProjectChecklist extends HTMLElement {
    static observedAttributes = ["data-created"];

    connectedCallback() {
      // Fetch early, so the variant is ready by the first click.
      void browserFlagsReady();
    }

    async attributeChangedCallback() {
      if (!this.hasAttribute("data-created")) return;
      const variant = await browserVariant("project-checklist");
      if (variant?.value === "on" && this.isConnected) this.hidden = false;
    }

  customElements.define("project-checklist", ProjectChecklist);
</script>
```

The form renders the gallery or the dropdown on the server, tracks `Project Created`, and tells the checklist:

src/components/NewProjectForm.astro

```html
---
import { TEMPLATES } from "../lib/templates";
import ProjectChecklist from "./ProjectChecklist.astro";

type Props = { defaultTemplate: string; showGallery: boolean };
const { defaultTemplate, showGallery } = Astro.props;
---

<new-project-form>
  {showGallery ? (
    <fieldset>
      <legend>Start from a template</legend>
      {TEMPLATES.map((t) => (
        <label>
          <input type="radio" name="template" value={t} checked={t === defaultTemplate} />
          {t}
        </label>
      ))}
    </fieldset>
  ) : (
    <select name="template">
      {TEMPLATES.map((t) => <option value={t} selected={t === defaultTemplate}>{t}</option>)}
    </select>
  )}
  <button type="button">Create project</button>
  <ProjectChecklist />
</new-project-form>

<script>
  import * as amplitude from "../scripts/amplitude";

  class NewProjectForm extends HTMLElement {
    connectedCallback() {
      this.querySelector("button")?.addEventListener("click", () => {
        // ... your create logic

        const template =
          this.querySelector<HTMLInputElement>('input[name="template"]:checked')?.value ??
          this.querySelector("select")?.value ??
          "blank";
        amplitude.track("Project Created", { template });
        this.querySelector("project-checklist")?.setAttribute("data-created", "");
      });
    }

  customElements.define("new-project-form", NewProjectForm);
</script>
```

The page reads `locals.flags`, sets the cache header, and renders both components. This replaces the contents of `src/pages/projects/new.astro`:

src/pages/projects/new\.astro

```html
---
// Replaces the version from /build/astro-product-analytics, which rendered
// CreateProjectForm. No other Astro page adds lines here.
import Layout from "../../layouts/Layout.astro";
import NewProjectForm from "../../components/NewProjectForm.astro";
import FlagExposure from "../../components/FlagExposure.astro";
import { TEMPLATES } from "../../lib/templates";

// Rendered on demand, so middleware evaluates new-project-flow per visitor.
// No adapter? Delete this line: the page prerenders with the defaults, and
// the browser flag still works.
export const prerender = false;

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

const amplitudeFlags = Astro.locals.flags;
// This HTML carries one visitor's device ID and variant. No cache may store
// it. Astro only lets the page itself set headers, not a component or layout.
if (amplitudeFlags) Astro.response.headers.set("Cache-Control", "private, no-store");

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

<Layout>
  <main>
    <h1>New project</h1>
    {amplitudeFlags && variant ? (
      <FlagExposure
        flagKey="new-project-flow"
        variant={variant.value}
        deviceId={amplitudeFlags.deviceId}
        userId={amplitudeFlags.userId}
      />
    ) : null}
    <NewProjectForm defaultTemplate={defaultTemplate} showGallery={showGallery} />
  </main>
</Layout>
```

`CreateProjectForm.astro` from the product analytics page stays in your project; this page renders `NewProjectForm` instead.

**Why.** The gallery is in the server HTML, so nothing swaps after load, and `<ClientRouter />` swaps it in the same way because it fetches the page from your server too. The server could track the exposure itself (`exposureConfig` on `initializeLocal`), but crawlers and abandoned loads also trigger server renders, and the [event tracking docs](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking) reserve server-side heuristics for cases where client-side exposure tracking isn't possible. No variant means the visitor is outside the rollout, so the page renders no `FlagExposure`: an exposure without a variant removes the user from the experiment. Render `FlagExposure` only on the page that shows the variant; in the layout it would count visitors who never opened `/projects/new`.

The cache header is the Astro trap on this page. Without it, a shared cache or CDN rule can hand one visitor's device ID and variant to everyone.

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

## Verify it worked

While you verify, raise both flags' rollouts to 100% so your test user lands in `on`; the server picks up the local flag's new rollout within 30 seconds. Run `npm run build`, then `npm run preview` with `AMPLITUDE_SERVER_DEPLOYMENT_KEY` set in its environment, so you're testing a production build. If your adapter doesn't support `astro preview`, start the built server the way its docs describe. Open `/projects/new` in a private window, signed out, and check step 1 on that first load; then sign in through your existing sign-in route and continue:

1. **Network tab.** Select the document request for `/projects/new`. Its response headers include `cache-control: private, no-store`, and on the first load in the private window, a `set-cookie` for `amp_server_device_id`. Its response contains Start from a template. Filter for `api2.amplitude.com`: a POST to `/2/httpapi` returns 200 after the page renders. Filter for `lab.amplitude.com`: a request to `api.lab.amplitude.com` goes out after load, and again when you open `/projects/new` after signing in.
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 shows `[Experiment] Exposure` with `[Experiment] Flag Key` set to `new-project-flow` and `[Experiment] Variant` set to `on`. Create a project: `Project Created` appears with `template: kanban`, followed by a second `[Experiment] Exposure` for `project-checklist`. Amplitude renames `$exposure` to `[Experiment] Exposure` on ingestion.
4. **Device ID match.** Open a new private window and load `/projects/new` directly, signed out. In the Instrumentation Explorer Chrome extension (the SDK compresses larger request bodies, so the Network tab payload is often unreadable), find the `$exposure` event with `flag_key` `new-project-flow`: its `device_id` equals the `amp_server_device_id` value under Application > Cookies. Reload, and the document response no longer sets the handoff cookie, because the server now reads the browser's `AMP_` cookie.
5. **Navigation.** If your layout renders `<ClientRouter />`, go to `/`, back to `/projects/new`, and create another project. No new request to `api.lab.amplitude.com` and no second exposure for either flag goes out in that visit. Without it, each full load of `/projects/new` fetches once and sends one exposure per flag.

Set both rollouts back to 50% when you're done.

## Troubleshooting

### The server never returns a variant

The flag isn't in Local mode, on the `astro-server` deployment, and Active, the rollout doesn't cover your test device, or `AMPLITUDE_SERVER_DEPLOYMENT_KEY` holds the key from the `web` deployment. Check the server log for the `[flags] evaluation failed` error. To watch the rules load, temporarily pass `{ logLevel: LogLevel.Debug }` to `initializeLocal`, importing `LogLevel` from `@amplitude/experiment-node-server`.

### The page always renders the dropdown, and no cache-control header comes back

`Astro.locals.flags` is `null`, so the page was prerendered. Check that `src/pages/projects/new.astro` exports `prerender = false`, that `/projects/new` is in `SERVER_FLAG_ROUTES`, and that your adapter is installed; Astro needs an adapter to render any page on demand, so with no adapter, delete the `prerender` line and the page prerenders with its defaults. `astro dev` renders on-demand pages even without an adapter, so a missing adapter only shows up as a `NoAdapterInstalled` error in `astro build`.

### Every visitor gets the same variant, or the same device ID

A cache is storing `/projects/new`. Check that the document response carries `cache-control: private, no-store`; if it doesn't, the `Astro.response.headers.set` line is missing from the page. Setting the header in a layout or component does nothing, because Astro has already sent the headers by then.

### The console warns that no exposure was sent for a flag

The browser's device or user doesn't match what the server evaluated, so no exposure is sent for that page. Confirm `src/scripts/amplitude.ts` passes `deviceId` only when there's no `AMP_` cookie and the handoff holds a UUID, and that nothing calls `setDeviceId` or `reset()` on load. The pattern assumes the default `identityStorage: 'cookie'`; with `localStorage`, the server mints a new ID on every request. A first visit that lands on a prerendered page and opens `/projects/new` within the first second can also do this once. If a session ends without the sign-out button, run the same `reset()` and `clear()` wherever your site ends a session.

### The checklist never appears, even at 100%

Confirm `project-checklist` is Active and on the `web` deployment, and that `PUBLIC_AMPLITUDE_DEPLOYMENT_KEY` holds that deployment's key, not the server key. If the variable is empty, the SDK falls back to the project API key's default deployment, which doesn't serve flags on `web`. Astro inlines it at build time, so rebuild after changing it. If no request to `api.lab.amplitude.com` goes out at all, `initAll` failed: check the console for "Amplitude failed to initialize". If a content blocker stopped the guides and surveys script, flags stop too; if you don't use guides and surveys, add `engagement: { skip: true }` to the `initAll` options so it never loads.

### `astro build` never exits, or fails with "AMPLITUDE\_SERVER\_DEPLOYMENT\_KEY is not set"

Something calls `startFlags()` or `loadFlags` outside a request rendered on demand, for example at the top of a module or in a prerendered page's frontmatter. Call `loadFlags` only from the middleware in step 4, behind the `context.isPrerendered` check.

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

EU projects need `serverZone: 'EU'` at the top level of the `initAll` options and `serverZone: 'eu'` in the `initializeLocal` options, with keys from the EU project.

## Full code reference

Every file this tutorial adds or changes, in one block, the version to hand to an agent. 14 files. Shared files start with a comment naming the lines other Astro pages add there; merge into your file and keep those lines. Unchanged from earlier pages; keep your versions: `src/layouts/Layout.astro`, `src/components/Amplitude.astro`, `src/lib/auth.ts`, `src/components/CreateProjectForm.astro`.

14 files

```html
// -- .env --
# Keep lines other Astro pages added here: PUBLIC_AMPLITUDE_API_KEY
# (/build/astro-product-analytics).
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
PUBLIC_AMPLITUDE_DEPLOYMENT_KEY=your-client-deployment-key
AMPLITUDE_SERVER_DEPLOYMENT_KEY=your-server-deployment-key

// -- src/lib/amplitude-cookies.ts --
export const DEVICE_ID_HANDOFF_COOKIE = "amp_server_device_id";

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export function isDeviceId(value: string | undefined): value is string {
  return value !== undefined && UUID.test(value);
}

export function readCookie(cookies: string, name: string): string | undefined {
  const prefix = `${name}=`;
  const match = cookies.split(/;\s*/).find((c) => c.startsWith(prefix));
  if (!match) return undefined;
  try {
    return decodeURIComponent(match.slice(prefix.length));
  } catch {
    return undefined;
  }

// -- src/scripts/amplitude.ts --
// Keep lines other Astro pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/astro-web-analytics); debugMode and privacyConfig
// (/build/astro-session-replay); the engagement block with renderCssInDom
// (/build/astro-guides-surveys); the optional webExperiment plugin lines below
// the amplitudeReady export (/build/astro-web-experiment). Import this module
// only from <script> tags, never from component frontmatter.
import * as amplitude from "@amplitude/unified";
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId, readCookie } from "../lib/amplitude-cookies";

export * from "@amplitude/unified";

const apiKey = import.meta.env.PUBLIC_AMPLITUDE_API_KEY;

if (!apiKey) {
  console.warn("PUBLIC_AMPLITUDE_API_KEY is not set; analytics is off.");
}

const hasAmpCookie =
  !!apiKey && readCookie(document.cookie, `AMP_${apiKey.slice(0, 10)}`) !== undefined;
const handoff = readCookie(document.cookie, DEVICE_ID_HANDOFF_COOKIE);
const serverDeviceId = !hasAmpCookie && isDeviceId(handoff) ? handoff : undefined;

export const amplitudeReady: Promise<void> = apiKey
  ? amplitude
      .initAll(apiKey, {
        analytics: {
          autocapture: {
            attribution: true,
            pageViews: true,
            sessions: true,
            formInteractions: true,
            fileDownloads: true,
            elementInteractions: true,
          },
          ...(serverDeviceId ? { deviceId: serverDeviceId } : {}),
        },
        sessionReplay: {
          // Every session while you build; about 1% in production.
          sampleRate: import.meta.env.DEV ? 1 : 0.01,
          // Ignores the focus check so replays record with DevTools open. Setup only.
          debugMode: import.meta.env.DEV,
          // Selectors for the data attributes in steps 3 and 4.
          privacyConfig: {
            maskSelector: ["[data-amp-mask]"],
            blockSelector: ["[data-amp-block]"],
            unmaskSelector: ["[data-amp-unmask]"],
          },
        experiment: {
          deploymentKey: import.meta.env.PUBLIC_AMPLITUDE_DEPLOYMENT_KEY,
          fetchTimeoutMillis: 3000,
        },
      })
      .catch((error) => {
        console.warn("Amplitude failed to initialize", error);
      })
  : Promise.resolve();

// -- src/scripts/flags.ts --
import * as amplitude from "./amplitude";

export type BrowserVariant = { value?: string; payload?: unknown };

let fetchedFor: string | undefined;
let fetching: Promise<boolean> = Promise.resolve(false);
const read = new Map<string, BrowserVariant>();

export async function browserFlagsReady(): Promise<boolean> {
  await amplitude.amplitudeReady;
  const client = amplitude.experiment();
  if (!client) return false;

  const identity = document.querySelector<HTMLElement>("amplitude-identity");
  const userId = identity?.dataset.userId ?? amplitude.getUserId() ?? undefined;
  const plan = identity?.dataset.plan;

  const key = userId ?? "anonymous";
  if (key !== fetchedFor) {
    fetchedFor = key;
    read.clear();
    fetching = client
      .fetch({
        ...(userId ? { user_id: userId } : {}),
        ...(plan ? { user_properties: { plan } } : {}),
      })
      .then(
        () => true,
      );
  }
  return fetching;
}

export async function browserVariant(flagKey: string): Promise<BrowserVariant | undefined> {
  if (!(await browserFlagsReady())) return undefined;
  const cached = read.get(flagKey);
  if (cached) return cached;
  const variant = amplitude.experiment()?.variant(flagKey);
  const result = { value: variant?.value ?? undefined, payload: variant?.payload };
  read.set(flagKey, result);
  return result;
}

// -- src/components/SignOutButton.astro --
---
// Keep lines other Astro pages added here: the getEngagement import and
// getEngagement()?.gs.closeAll() before reset() (/build/astro-guides-surveys).
// Order: closeAll, reset, clear.
---

<sign-out-button>
  <form method="post" action="/logout" data-astro-reload>
    <button type="submit">Sign out</button>
  </form>
</sign-out-button>

<script>
  import * as amplitude from "../scripts/amplitude";

  class SignOutButton extends HTMLElement {
    connectedCallback() {
      this.querySelector("form")?.addEventListener("submit", () => {
        amplitude.reset();
        amplitude.experiment()?.clear();
        try {
          sessionStorage.removeItem("amplitude-identity");
        } catch {}
      });
    }

  customElements.define("sign-out-button", SignOutButton);
</script>

// -- src/lib/experiment.ts --
// Server only: import from middleware or frontmatter, never from a <script>.
import { Experiment } from "@amplitude/experiment-node-server";
import { getSecret } from "astro:env/server";

type LocalClient = ReturnType<typeof Experiment.initializeLocal>;
export type ServerVariants = ReturnType<LocalClient["evaluateV2"]>;
export type ExperimentUser = {
  user_id?: string;
  device_id?: string;
  user_properties?: Record<string, string>;
};

const store = globalThis as typeof globalThis & {
  __amplitudeFlags?: { client: LocalClient; ready: Promise<void> };
};

export function startFlags(): Promise<void> {
  if (!store.__amplitudeFlags) {
    const key = getSecret("AMPLITUDE_SERVER_DEPLOYMENT_KEY");
    if (!key) return Promise.reject(new Error("AMPLITUDE_SERVER_DEPLOYMENT_KEY is not set"));
    const client = Experiment.initializeLocal(key);
    const ready = client.start().catch((error) => {
      client.stop();
      if (store.__amplitudeFlags?.client === client) store.__amplitudeFlags = undefined;
      throw error;
    });
    store.__amplitudeFlags = { client, ready };
  }
  return store.__amplitudeFlags.ready;
}

export async function evaluateFlags(
  user: ExperimentUser,
  flagKeys: string[],
): Promise<ServerVariants> {
  if (!user.user_id && !user.device_id) return {};
  let timer: ReturnType<typeof setTimeout> | undefined;
  try {
    const started = startFlags();
    const current = store.__amplitudeFlags?.client;
    await Promise.race([
      started,
      new Promise<never>((_, reject) => {
        timer = setTimeout(() => reject(new Error("flag rules not loaded within 1 s")), 1000);
      }),
    ]);
    return current ? current.evaluateV2(user, flagKeys) : {};
  } catch (error) {
    console.error("[flags] evaluation failed; rendering defaults.", error);
    return {};
  } finally {
    clearTimeout(timer);
  }

// -- src/lib/server-flags.ts --
// Server only: import from middleware or frontmatter, never from a <script>.
import { randomUUID } from "node:crypto";
import type { APIContext } from "astro";
import { AmplitudeCookie } from "@amplitude/experiment-node-server";
import { DEVICE_ID_HANDOFF_COOKIE, isDeviceId } from "./amplitude-cookies";
import { evaluateFlags } from "./experiment";
import type { User } from "./auth";

export const SERVER_FLAG_KEYS = ["new-project-flow"];

// Routes whose pages render those flags. Other requests, such as API
// endpoints, skip evaluation and get no handoff cookie.
export const SERVER_FLAG_ROUTES = ["/projects/new"];

export type FlagVariant = { value: string; payload?: unknown };
export type ServerFlags = {
  deviceId: string;
  userId?: string;
  flags: Record<string, FlagVariant>;
};

type Cookies = APIContext["cookies"];

function readDeviceId(cookies: Cookies): string | undefined {
  const apiKey = import.meta.env.PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) return undefined;
  try {
    const value = cookies.get(AmplitudeCookie.cookieName(apiKey, true))?.value;
    return value ? (AmplitudeCookie.parse(value, true).device_id ?? undefined) : undefined;
  } catch {
    return undefined;
  }

export async function loadFlags(
  cookies: Cookies,
  user: User | null,
  flagKeys: string[],
): Promise<ServerFlags> {
  let deviceId = readDeviceId(cookies);
  if (!deviceId) {
    const handoff = cookies.get(DEVICE_ID_HANDOFF_COOKIE)?.value;
    if (isDeviceId(handoff)) deviceId = handoff;
  }
  if (!deviceId) {
    deviceId = randomUUID();
    cookies.set(DEVICE_ID_HANDOFF_COOKIE, deviceId, { path: "/", maxAge: 3600, sameSite: "lax" });
  }

  const userId = user ? `user-${user.id}` : undefined;

  const variants = await evaluateFlags(
    {
      device_id: deviceId,
      user_id: userId,
      ...(user ? { user_properties: { plan: user.plan ?? "unknown" } } : {}),
    },
    flagKeys,
  );

  const flags: ServerFlags["flags"] = {};
  for (const [flagKey, variant] of Object.entries(variants)) {
    if (variant?.value && variant.value !== "off") {
      flags[flagKey] = { value: variant.value, payload: variant.payload };
    }

  return { deviceId, userId, flags };
}

// -- src/middleware.ts --
// Keep lines other Astro pages added here: the user lookup
// (/build/astro-product-analytics). If you already have middleware, combine both
// with sequence().
import { defineMiddleware } from "astro:middleware";
import { getUser } from "./lib/auth";
import { loadFlags, SERVER_FLAG_KEYS, SERVER_FLAG_ROUTES } from "./lib/server-flags";

export const onRequest = defineMiddleware(async (context, next) => {
  const user = context.isPrerendered ? null : await getUser(context.request);
  context.locals.user = user;
  context.locals.flags =
    !context.isPrerendered &&
    context.request.method === "GET" &&
    SERVER_FLAG_ROUTES.includes(context.routePattern)
      ? await loadFlags(context.cookies, user, SERVER_FLAG_KEYS)
      : null;
  return next();
});

// -- src/env.d.ts --
// Keep lines other Astro pages added here: the user field in Locals
// (/build/astro-product-analytics). Add the Locals fields to any declarations you
// already have.
declare namespace App {
  interface Locals {
    user: import("./lib/auth").User | null;
    flags: import("./lib/server-flags").ServerFlags | null;
  }

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

// -- src/components/FlagExposure.astro --
---
type Props = { flagKey: string; variant: string; deviceId: string; userId?: string };
const { flagKey, variant, deviceId, userId } = Astro.props;
---

<flag-exposure
  hidden
  data-flag-key={flagKey}
  data-variant={variant}
  data-device-id={deviceId}
  data-user-id={userId}
></flag-exposure>

<script>
  import * as amplitude from "../scripts/amplitude";

  const sent = new Set<string>();

  class FlagExposure extends HTMLElement {
    async connectedCallback() {
      const { flagKey, variant, deviceId, userId } = this.dataset;
      if (!flagKey || !variant || !deviceId) return;
      const key = `${userId ?? ""}|${deviceId}|${flagKey}|${variant}`;
      if (sent.has(key)) return;
      sent.add(key);

      await amplitude.amplitudeReady;
      const browserDeviceId = amplitude.getDeviceId();
      const browserUserId = amplitude.getUserId();
      const sameUser = !userId || !browserUserId || browserUserId === userId;
      if (browserDeviceId !== deviceId || !sameUser) {
        if (import.meta.env.DEV) {
          console.warn(
            `[flags] no exposure for ${flagKey}: the browser's device or user differs from what the server evaluated.`,
          );
        }
        return;
      }

      amplitude.track({
        event_type: "$exposure",
        event_properties: { flag_key: flagKey, variant },
        ...(userId && !browserUserId ? { user_id: userId } : {}),
      });
    }

  customElements.define("flag-exposure", FlagExposure);
</script>

// -- src/components/ProjectChecklist.astro --
---

<project-checklist hidden>
  <aside>
    <h2>Next steps</h2>
    <ul>
      <li>Invite your team</li>
      <li>Import your existing tasks</li>
    </ul>
  </aside>
</project-checklist>

<script>
  import { browserFlagsReady, browserVariant } from "../scripts/flags";

  class ProjectChecklist extends HTMLElement {
    static observedAttributes = ["data-created"];

    connectedCallback() {
      void browserFlagsReady();
    }

    async attributeChangedCallback() {
      if (!this.hasAttribute("data-created")) return;
      const variant = await browserVariant("project-checklist");
      if (variant?.value === "on" && this.isConnected) this.hidden = false;
    }

  customElements.define("project-checklist", ProjectChecklist);
</script>

// -- src/components/NewProjectForm.astro --
---
import { TEMPLATES } from "../lib/templates";
import ProjectChecklist from "./ProjectChecklist.astro";

type Props = { defaultTemplate: string; showGallery: boolean };
const { defaultTemplate, showGallery } = Astro.props;
---

<new-project-form>
  {showGallery ? (
    <fieldset>
      <legend>Start from a template</legend>
      {TEMPLATES.map((t) => (
        <label>
          <input type="radio" name="template" value={t} checked={t === defaultTemplate} />
          {t}
        </label>
      ))}
    </fieldset>
  ) : (
    <select name="template">
      {TEMPLATES.map((t) => <option value={t} selected={t === defaultTemplate}>{t}</option>)}
    </select>
  )}
  <button type="button">Create project</button>
  <ProjectChecklist />
</new-project-form>

<script>
  import * as amplitude from "../scripts/amplitude";

  class NewProjectForm extends HTMLElement {
    connectedCallback() {
      this.querySelector("button")?.addEventListener("click", () => {
        // ... your create logic

        const template =
          this.querySelector<HTMLInputElement>('input[name="template"]:checked')?.value ??
          this.querySelector("select")?.value ??
          "blank";
        amplitude.track("Project Created", { template });
        this.querySelector("project-checklist")?.setAttribute("data-created", "");
      });
    }

  customElements.define("new-project-form", NewProjectForm);
</script>

// -- src/pages/projects/new.astro --
---
// Replaces the version from /build/astro-product-analytics, which rendered
// CreateProjectForm. No other Astro page adds lines here.
import Layout from "../../layouts/Layout.astro";
import NewProjectForm from "../../components/NewProjectForm.astro";
import FlagExposure from "../../components/FlagExposure.astro";
import { TEMPLATES } from "../../lib/templates";

// No adapter? Delete this line: the page prerenders with the defaults.
export const prerender = false;

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

const amplitudeFlags = Astro.locals.flags;
if (amplitudeFlags) Astro.response.headers.set("Cache-Control", "private, no-store");

const variant = amplitudeFlags?.flags["new-project-flow"];
const config = (variant?.value === "on" ? variant.payload : undefined) as
  | NewProjectPayload
  | undefined;
const candidate = config?.defaultTemplate;
const defaultTemplate =
  typeof candidate === "string" && TEMPLATES.includes(candidate) ? candidate : "blank";
const showGallery = config?.showTemplateGallery === true;
---

<Layout>
  <main>
    <h1>New project</h1>
    {amplitudeFlags && variant ? (
      <FlagExposure
        flagKey="new-project-flow"
        variant={variant.value}
        deviceId={amplitudeFlags.deviceId}
        userId={amplitudeFlags.userId}
      />
    ) : null}
    <NewProjectForm defaultTemplate={defaultTemplate} showGallery={showGallery} />
  </main>
</Layout>
```

## 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
Complete https://amplitude.com/build/astro-product-analytics first, or
confirm this Astro site has its setup, including src/scripts/amplitude.ts,
the <amplitude-identity> element in src/components/Amplitude.astro, the
src/lib/auth.ts stub, src/middleware.ts, and src/pages/projects/new.astro;
create them if missing. Then follow
https://amplitude.com/build/astro-feature-flags exactly, using its Full code
reference. I'll create the astro-server and web deployments and the
new-project-flow (local) and project-checklist (remote) flags; ask me for
the keys. Put the client key in PUBLIC_AMPLITUDE_DEPLOYMENT_KEY and the
server key in AMPLITUDE_SERVER_DEPLOYMENT_KEY (no PUBLIC_ prefix, read with
getSecret from astro:env/server). Import @amplitude/experiment-node-server
only from src/lib modules that middleware uses, never from a <script>.
Start the local evaluation client lazily on the first evaluation, never at
module load, and wait at most 1000 ms before rendering defaults. Evaluate
server flags in middleware only for GET requests to the routes in
SERVER_FLAG_ROUTES, and only when context.isPrerendered is false. The
page sets Cache-Control: private, no-store itself. Send $exposure from the
browser only, and only when the browser's device ID equals the server's
and the user ID matches or is unset. Fetch browser flags after
amplitudeReady with the identity element's user, once per identity, and
call variant() only where the variant shows. Use custom elements for every
DOM handler. Never call reset() when the user is null. If no adapter is
installed, delete the prerender line in src/pages/projects/new.astro and
tell me. Merge into existing shared files (.env, src/scripts/amplitude.ts,
src/middleware.ts, src/env.d.ts, src/components/SignOutButton.astro) and
never delete lines another Amplitude Build page added; each file's first
comment names them. Don't hardcode keys or invent flag or event names.
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 `[Experiment] Exposure` as the exposure event and `Project Created` as the primary metric, counting only people whose browser rendered the gallery.
- [Session replay](https://amplitude.com/session-replay) can filter to users with the `[Experiment] new-project-flow` user property, and each replay shows the server-rendered variant from the first frame.
- [Heatmaps](https://amplitude.com/heatmaps) on `/projects/new`, built from those replays, never mix gallery clicks with clicks on a loading state.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can target the `[Experiment] project-checklist` user property to ask whether the checklist helped.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add Web Experiment to an Astro siteTest copy and layout changes on prerendered pages with the visual editor, no flag code required.](/build/astro-web-experiment)[Web frameworksHow to add session replay to an Astro siteTune replay sampling and masking so you can watch each variant of /projects/new in use.](/build/astro-session-replay)[Web frameworksHow to add heatmaps to an Astro siteSee where people click on the gallery and the dropdown, built from the same replays.](/build/astro-heatmaps)

Reference docs

[Experiment JavaScript SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-javascript)[Experiment Node.js SDK →](https://amplitude.com/docs/sdks/experiment-sdks/experiment-node-js)[Local evaluation →](https://amplitude.com/docs/feature-experiment/local-evaluation)[Event tracking →](https://amplitude.com/docs/feature-experiment/under-the-hood/event-tracking)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[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/astro-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)
