---
title: "How to add product analytics to an Astro site"
description: "Add Amplitude to an Astro site from one client module, capture page views with or without the ClientRouter, identify signed-in users, and track events."
canonical: "https://amplitude.com/en-us/build/astro-product-analytics"
---

# How to add product analytics to an Astro site

Add Amplitude to an Astro site from one client module, capture page views with or without the ClientRouter, identify signed-in users, and track events.

Source: https://amplitude.com/en-us/build/astro-product-analytics

---

# How to add product analytics to an Astro site

Install the Amplitude Unified SDK in an Astro site from one client-side module, capture page views on full page loads and \<ClientRouter /> navigations, identify signed-in users from middleware, and track one custom event. About 10 minutes, all copy-paste.

- Astro 6+
- Static or on-demand
- Unified SDK 1.x
- 10 min

Chris Tam · Last updated 19 October 2026

Astro ships HTML first and adds JavaScript only where you ask for it, so most pages are static files with a few interactive pieces. [Product analytics](https://amplitude.com/amplitude-analytics) shows you what people do on those pages: which ones they read, what they click, and where they leave.

In this tutorial, you'll add Amplitude to an Astro site from one client module, capture page views, identify signed-in users, and track your first custom event.

## What you'll build

An Astro site that sends `[Amplitude] Page Viewed` on every page, attaches a `user-` prefixed user ID and a `plan` user property once someone signs in, and tracks `Project Created` with a `template` property from a `/projects/new` page. By the end you'll see those events on your test user's profile in Amplitude.

## Prerequisites

- An Astro 6 or later project with TypeScript, for example one created with `npm create astro@latest`. Astro 6 needs Node.js 22.12 or later, and Astro 7.4 and later need 22.22 or later.
- Either output mode works. Every page is prerendered by default; signed-in identity in step 4 needs at least one page rendered on demand, which means an adapter and `export const prerender = false` on that page (or `output: 'server'`).
- An auth helper that reads the signed-in user from the request, for example `getUser(request)` in `src/lib/auth.ts`, returning an object with an `id` and a `plan`, or `null`. No auth yet? Step 4 gives you a stub.
- An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.

No Amplitude account yet? A free account covers the tracking on this page. [Sign up for free](https://app.amplitude.com/signup) to get your project API key. Your session replay capture quota depends on your plan; [compare plans](https://amplitude.com/pricing).

## 1. Install the SDK and add the key

The [Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) installs `@amplitude/unified` for browser apps that use npm. Astro bundles your client scripts, so that's the install to use here. It bundles analytics, session replay, Feature Experiment, and guides and surveys behind one init call.

Terminal

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

Astro only exposes env vars prefixed with `PUBLIC_` to client-side code, and it replaces them at build time. The project API key is public and its scope is limited to the minimum needed to ingest data, so shipping it to the browser is expected, but keep it out of source anyway.

.env

```sh
# Keep lines other Astro pages added here: PUBLIC_AMPLITUDE_DEPLOYMENT_KEY and
# AMPLITUDE_SERVER_DEPLOYMENT_KEY (/build/astro-feature-flags).
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
```

## 2. Initialize in one client module

Astro has no client entry file. Each `<script>` tag in a component is bundled as a module, and a module runs once per page load, so put `initAll` at the top level of one module and have every other client script import from it. Importing the module guarantees `initAll` has been called before any `track` or `setUserId` call runs.

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 amplitude-cookies import, the device ID
// handoff lines, the deviceId spread, and the experiment block
// (/build/astro-feature-flags); 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";

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.");
}

// Resolves once initAll finishes. Feature flags and guides and surveys
// wait on it; nothing on this page needs to.
export const amplitudeReady: Promise<void> = apiKey
  ? amplitude
      .initAll(apiKey, {
        analytics: {
          autocapture: {
            attribution: true,
            pageViews: true,
            sessions: true,
            formInteractions: true,
            fileDownloads: true,
            elementInteractions: true,
          },
        sessionReplay: {
          // Every session while you build; about 1% in production.
          sampleRate: import.meta.env.DEV ? 1 : 0.01,
        },
      })
      .catch((error) => {
        console.warn("Amplitude failed to initialize", error);
      })
  : Promise.resolve();
```

Load it from a small `Amplitude.astro` component. It also renders the identity element you'll fill in during step 4; for now, create it with just the script.

src/components/Amplitude.astro

```html
---
// src/components/Amplitude.astro (complete in step 4)
---

<script>
  import "../scripts/amplitude";
</script>
```

Render it once in your shared layout, as the first thing in `<body>`. If your layout has no frontmatter yet (the `create astro` starter's doesn't), add the `---` fences at the top of the file. Every page that uses the layout now loads the SDK.

src/layouts/Layout.astro

```html
---
// src/layouts/Layout.astro (frontmatter: add this import; keep the rest)
// Keep lines other Astro pages added here: the AmpMask import, MASKED_PATHS, the
// masked <slot /> wrapper, and the .masked-section style
// (/build/astro-session-replay); the experimentSrc constants and the is:inline
// experiment <script> first in <head> (/build/astro-web-experiment); the
// FeedbackButton import and <header><FeedbackButton /></header> after <Amplitude
// /> (/build/astro-guides-surveys).
import Amplitude from "../components/Amplitude.astro";
---

<!-- In <body>, before <slot />: -->
<Amplitude />
```

**Why.** Code in an `.astro` file's frontmatter, between the `---` fences, runs on the server or at build time, where there's no `window`. A `<script>` tag runs in the browser. Astro also includes a component's script only once per page, however many times the component renders, so you get one SDK instance per page load without a guard.

`@amplitude/unified` turns on session replay when `sessionReplay.sampleRate` is above 0. This config records every session in development and about 1% in production, which is where Amplitude recommends starting; raise it over a few days. Set it to `0` to turn session replay off. For the full option list, see the [Browser Unified SDK reference](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk).

## 3. Let autocapture handle navigation

There's no code in this step. With `pageViews: true`, the SDK sends `[Amplitude] Page Viewed` when it initializes and again on every URL change. That covers both ways an Astro site navigates:

- Without `<ClientRouter />`, every link is a full page load. The module runs again on the new page, `initAll` runs again, and the new page view is sent on init. The device ID and session carry over, because the SDK stores them in the browser.
- With `<ClientRouter />` in your layout's `<head>`, the router swaps the page in place and pushes the new URL to history. The module doesn't run again, and the SDK sends the page view on the URL change.

Don't add an `astro:page-load` listener that calls `track('Page Viewed')`. It produces a second, differently named page view on every navigation.

## 4. Identify the signed-in user

Until you set a user ID, events belong to an anonymous device ID. Set it as soon as a page knows who's signed in, and reset only when someone signs out, so the next person on the same browser isn't merged into the last one.

No auth yet? Add this stub so the middleware compiles; every visitor stays anonymous until you replace it with your auth library's lookup.

src/lib/auth.ts

```typescript
// src/lib/auth.ts (stub until you add auth)
export type User = { id: string | number; plan?: string | null };

export async function getUser(_request: Request): Promise<User | null> {
  return null;
}
```

Read the user once per request in middleware and store it on `locals`. A prerendered page has no request to read cookies from, so skip the lookup there.

src/middleware.ts

```typescript
// Keep lines other Astro pages added here: the server-flags import and
// context.locals.flags (/build/astro-feature-flags). If you already have
// middleware, add the user lookup to it, or combine both with sequence().
import { defineMiddleware } from "astro:middleware";
import { getUser } from "./lib/auth";

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

Type `locals.user` so components can read it:

src/env.d.ts

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

Now finish `Amplitude.astro`. It reads the user in its frontmatter, on the server, and hands it to the browser as data attributes on a custom element. The element's script sets the user ID when the element connects.

src/components/Amplitude.astro

```html
---
// Keep lines other Astro pages added here: the second <script> importing
// ../scripts/engagement-bridge and ../scripts/engagement-swap
// (/build/astro-guides-surveys).
const user = Astro.locals.user ?? null;
const userId = user ? `user-${user.id}` : undefined;
const plan = user ? (user.plan ?? "unknown") : undefined;
---

<amplitude-identity data-user-id={userId} data-plan={plan}></amplitude-identity>

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

  const STORAGE_KEY = "amplitude-identity";

  class AmplitudeIdentity extends HTMLElement {
    connectedCallback() {
      const userId = this.dataset.userId;
      const plan = this.dataset.plan ?? "unknown";

      // No user on this page: a prerendered page, an anonymous visitor, or
      // a signed-out user. Keep the current IDs. Reset only on sign-out.
      if (!userId) return;

      // The element connects on every page load and every ClientRouter
      // navigation. Skip it when nothing changed.
      const identity = `${userId}|${plan}`;
      let stored: string | null = null;
      try {
        stored = sessionStorage.getItem(STORAGE_KEY);
      } catch {}
      if (stored === identity) return;

      // userId is "user-" plus your ID: Amplitude drops IDs shorter than
      // 5 characters. Use the same string your server sends as user_id.
      amplitude.setUserId(userId);

      const identify = new amplitude.Identify();
      identify.set("plan", plan);
      amplitude.identify(identify);

      try {
        sessionStorage.setItem(STORAGE_KEY, identity);
      } catch {}
    }

  customElements.define("amplitude-identity", AmplitudeIdentity);
</script>
```

Once set, the SDK keeps the user ID in the browser, so events from prerendered pages carry it too, even though those pages render no user. Pages rendered on demand now carry the signed-in user in their HTML, so don't let a shared cache store them.

Reset from your sign-out form, not from the identity element. If you don't have a sign-out route yet, skip SignOutButton.astro until you do.

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);
// amplitude.experiment()?.clear() after reset() (/build/astro-feature-flags).
// 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();
        try {
          sessionStorage.removeItem("amplitude-identity");
        } catch {}
      });
    }

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

Point `action` at your own sign-out route. Render `<SignOutButton />` wherever your signed-in navigation lives. `data-astro-reload` makes `<ClientRouter />` submit the form with a full page load, so the SDK starts fresh for the next person instead of carrying the previous user's state into the signed-out page; without the router, a form submission is a full load anyway.

**Why.** With `<ClientRouter />`, the router replaces the whole `<body>` on every navigation and bundled scripts don't run again, so a listener added with `document.querySelector` in a plain script stops working after the first navigation. A custom element's `connectedCallback` runs each time the element lands in the page, on a full load or a swap, so the listener comes back with it. It also means a `null` user never reaches `reset()`: a missing user is every anonymous visitor and every prerendered page, and resetting there would issue a new device ID on each of them.

**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 existing event taxonomy before it writes `track` calls, so new events match names you already use instead of inventing `projectCreated` next to `Project Created`.

## 5. Track one custom event

Autocapture covers page views, sessions, forms, and clicks. Track the events that mean something to your product yourself, from a handler that only runs in the browser. The same custom element pattern keeps the handler alive across `<ClientRouter />` navigations.

src/components/CreateProjectForm.astro

```html
<create-project-form>
  <select name="template">
    <option value="blank">Blank</option>
    <option value="kanban">Kanban</option>
  </select>
  <button type="button">Create project</button>
</create-project-form>

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

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

        amplitude.track("Project Created", {
          template: select?.value ?? "blank",
        });
    }

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

Using a React, Vue, or Svelte island instead? Call `track` from the island's event handler and import it from `src/scripts/amplitude.ts`, not from `@amplitude/unified`, so the init has run first. Don't call `track` in component frontmatter, an endpoint, or middleware, which run on the server. For events your server should send, such as a payment confirmed in an API route, use the Node.js SDK there; the [Node.js product analytics page](/build/nodejs-product-analytics) covers identity and flushing on the server.

Give the form a page at `/projects/new`. Astro routes by file, so creating the file creates the route. Later Astro pages link to it, and the feature flags page replaces its contents with a flag-driven version.

src/pages/projects/new\.astro

```html
---
// Keep lines other Astro pages added here: /build/astro-feature-flags replaces
// this file with a flag-driven version; if you have that version, keep it.
import Layout from "../../layouts/Layout.astro";
import CreateProjectForm from "../../components/CreateProjectForm.astro";
---

<Layout>
  <main>
    <h1>New project</h1>
    <CreateProjectForm />
  </main>
</Layout>
```

## Verify it worked

Run `npm run dev`, open `/`, then `/projects/new`, create a project, then check in this order:

1. **Network tab.** Filter for `api2.amplitude.com`. You should see a POST to `/2/httpapi` returning 200 within about a second of each page. Requests start only once the module runs in the browser; with the SDK imported only from `<script>` tags, the build and the server render send none.
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. The event stream shows `[Amplitude] Page Viewed` for each page and `Project Created` with its `template` property within ten seconds to a minute, and the profile shows `plan`. Using the auth stub? Search the device ID instead; the [Instrumentation Explorer](https://amplitude.com/docs/analytics/debug-analytics#instrumentation-explorer) Chrome extension shows it as `device_id` on each event; `plan` appears once a real user signs in on a page rendered on demand. You'll also see autocapture events such as `session_start`, `[Amplitude] Element Clicked`, and `[Guides-Surveys] Engagement Booted`; the Unified SDK initializes guides and surveys by default, even before you configure any. Not using guides and surveys? Add `engagement: { skip: true }` to the `initAll` options to keep it from loading, and remove it before you follow [the guides and surveys page](/build/astro-guides-surveys).

Check a production build too: run `npm run build` and `npm run preview`, then repeat step 1. That's the build that reads `PUBLIC_AMPLITUDE_API_KEY` the way your host will.

To watch payloads in the console, add `logLevel: amplitude.Types.LogLevel.Debug` to the `analytics` block in development.

## Troubleshooting

### The console says "PUBLIC\_AMPLITUDE\_API\_KEY is not set"

Astro replaces `import.meta.env` values when it builds, and the dev server reads `.env` when it starts. Restart the dev server after editing `.env`, and set the variable in your host's build environment, not only at runtime. The name must start with `PUBLIC_`.

### No requests in the Network tab and no warning in the console

The page doesn't render `<Amplitude />`. Check that the page uses your shared layout and that the layout renders `<Amplitude />` inside `<body>`. A `<script>` with any attribute other than `src` or `type="module"`, such as `is:inline` or `defer`, isn't bundled and can't import from npm; keep the script tag in `Amplitude.astro` bare.

### Events you track in frontmatter, middleware, or an endpoint never show up

That code runs on the server or at build time, not in the browser. Importing `src/scripts/amplitude.ts` there doesn't throw an error: the SDK initializes in Node, fetches remote configuration during the build, and drops the events. Import the SDK only inside `<script>` tags and island event handlers. For server events, use the [Node.js SDK](/build/nodejs-product-analytics).

### The custom event stops firing after the first navigation

The handler was attached with `document.querySelector` in a plain script, and `<ClientRouter />` replaced the element. Use the custom element from step 5, or wrap the setup in an `astro:page-load` listener.

### Two page view events per navigation

An `astro:page-load` listener or a page view helper is running alongside autocapture. Delete it.

### Signed-in events still show as anonymous

The identity element never gets a user. The page is prerendered, so the middleware skips the lookup; add `export const prerender = false` to the page the user lands on after signing in, and check that your adapter is installed. On a page rendered on demand, check that `getUser` returns the user from the request's cookies.

### Events send but never appear in the project

EU projects need `serverZone: 'EU'` at the top level of the `initAll` options and an API key from the EU project.

### Autocapture does something different from the code

Remote configuration is on by default, so autocapture settings saved in Amplitude under Data > Settings > Autocapture take precedence over the `autocapture` block in `src/scripts/amplitude.ts`. To keep autocapture under code control, add `remoteConfig: { fetchRemoteConfig: false }` to the `analytics` block.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent. 10 files. Shared files start with a comment naming the lines other Astro pages add there; merge into your file and keep those lines.

10 files

```html
// -- .env --
# Keep lines other Astro pages added here: PUBLIC_AMPLITUDE_DEPLOYMENT_KEY and
# AMPLITUDE_SERVER_DEPLOYMENT_KEY (/build/astro-feature-flags).
PUBLIC_AMPLITUDE_API_KEY=your-project-api-key

// -- 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 amplitude-cookies import, the device ID
// handoff lines, the deviceId spread, and the experiment block
// (/build/astro-feature-flags); 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";

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.");
}

// Resolves once initAll finishes. Feature flags and guides and surveys
// wait on it; nothing on this page needs to.
export const amplitudeReady: Promise<void> = apiKey
  ? amplitude
      .initAll(apiKey, {
        analytics: {
          autocapture: {
            attribution: true,
            pageViews: true,
            sessions: true,
            formInteractions: true,
            fileDownloads: true,
            elementInteractions: true,
          },
        sessionReplay: {
          // Every session while you build; about 1% in production.
          sampleRate: import.meta.env.DEV ? 1 : 0.01,
        },
      })
      .catch((error) => {
        console.warn("Amplitude failed to initialize", error);
      })
  : Promise.resolve();

// -- src/layouts/Layout.astro (frontmatter: add this import; keep the rest) --
---
// Keep lines other Astro pages added here: the AmpMask import, MASKED_PATHS, the
// masked <slot /> wrapper, and the .masked-section style
// (/build/astro-session-replay); the experimentSrc constants and the is:inline
// experiment <script> first in <head> (/build/astro-web-experiment); the
// FeedbackButton import and <header><FeedbackButton /></header> after <Amplitude
// /> (/build/astro-guides-surveys).
import Amplitude from "../components/Amplitude.astro";
---

<!-- In <body>, before <slot />: -->
<Amplitude />

// -- src/lib/auth.ts (stub until you add auth) --
export type User = { id: string | number; plan?: string | null };

export async function getUser(_request: Request): Promise<User | null> {
  return null;
}

// -- src/middleware.ts --
// Keep lines other Astro pages added here: the server-flags import and
// context.locals.flags (/build/astro-feature-flags). If you already have
// middleware, add the user lookup to it, or combine both with sequence().
import { defineMiddleware } from "astro:middleware";
import { getUser } from "./lib/auth";

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

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

// -- src/components/Amplitude.astro --
---
// Keep lines other Astro pages added here: the second <script> importing
// ../scripts/engagement-bridge and ../scripts/engagement-swap
// (/build/astro-guides-surveys).
const user = Astro.locals.user ?? null;
const userId = user ? `user-${user.id}` : undefined;
const plan = user ? (user.plan ?? "unknown") : undefined;
---

<amplitude-identity data-user-id={userId} data-plan={plan}></amplitude-identity>

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

  const STORAGE_KEY = "amplitude-identity";

  class AmplitudeIdentity extends HTMLElement {
    connectedCallback() {
      const userId = this.dataset.userId;
      const plan = this.dataset.plan ?? "unknown";

      // No user on this page: a prerendered page, an anonymous visitor, or
      // a signed-out user. Keep the current IDs. Reset only on sign-out.
      if (!userId) return;

      // The element connects on every page load and every ClientRouter
      // navigation. Skip it when nothing changed.
      const identity = `${userId}|${plan}`;
      let stored: string | null = null;
      try {
        stored = sessionStorage.getItem(STORAGE_KEY);
      } catch {}
      if (stored === identity) return;

      // userId is "user-" plus your ID: Amplitude drops IDs shorter than
      // 5 characters. Use the same string your server sends as user_id.
      amplitude.setUserId(userId);

      const identify = new amplitude.Identify();
      identify.set("plan", plan);
      amplitude.identify(identify);

      try {
        sessionStorage.setItem(STORAGE_KEY, identity);
      } catch {}
    }

  customElements.define("amplitude-identity", AmplitudeIdentity);
</script>

// -- src/components/SignOutButton.astro --
---
// Keep lines other Astro pages added here: the getEngagement import and
// getEngagement()?.gs.closeAll() before reset() (/build/astro-guides-surveys);
// amplitude.experiment()?.clear() after reset() (/build/astro-feature-flags).
// 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();
        try {
          sessionStorage.removeItem("amplitude-identity");
        } catch {}
      });
    }

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

// -- src/components/CreateProjectForm.astro --
---
// src/components/CreateProjectForm.astro
---

<create-project-form>
  <select name="template">
    <option value="blank">Blank</option>
    <option value="kanban">Kanban</option>
  </select>
  <button type="button">Create project</button>
</create-project-form>

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

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

        amplitude.track("Project Created", {
          template: select?.value ?? "blank",
        });
    }

  customElements.define("create-project-form", CreateProjectForm);
</script>

// -- src/pages/projects/new.astro --
---
// Keep lines other Astro pages added here: /build/astro-feature-flags replaces
// this file with a flag-driven version; if you have that version, keep it.
import Layout from "../../layouts/Layout.astro";
import CreateProjectForm from "../../components/CreateProjectForm.astro";
---

<Layout>
  <main>
    <h1>New project</h1>
    <CreateProjectForm />
  </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
Add Amplitude analytics to this Astro site by following
https://amplitude.com/build/astro-product-analytics exactly. Install
@amplitude/unified. Read PUBLIC_AMPLITUDE_API_KEY from import.meta.env.
Create src/scripts/amplitude.ts that calls initAll once at the top level,
with autocapture elementInteractions on, re-exports everything from
@amplitude/unified, and exports the amplitudeReady promise. Every client
script imports the SDK from src/scripts/amplitude.ts, never from
@amplitude/unified directly, and never from component frontmatter,
middleware, or endpoints. Create src/components/Amplitude.astro with the
<amplitude-identity> custom element and render <Amplitude /> once in the
shared layout's <body>, before <slot />. Don't add an astro:page-load page
view listener; autocapture tracks page loads and ClientRouter navigations.
Merge into existing shared files (.env, src/scripts/amplitude.ts,
src/layouts/Layout.astro, src/middleware.ts, src/env.d.ts,
src/components/Amplitude.astro, src/components/SignOutButton.astro,
src/pages/projects/new.astro) and never delete lines another Amplitude
Build page added; each file's first comment names them. Read the user in
src/middleware.ts only when context.isPrerendered is false, using my
existing auth helper, or create the page's src/lib/auth.ts stub (getUser
returns null) if none exists. Set the user ID with a "user-" prefix in the
identity element, skipping when the identity stored in sessionStorage
already matches. Call reset() only from the sign-out form's submit handler, never
when the user is null. If there's no sign-out route, skip
SignOutButton.astro and tell me. Use custom elements for every DOM handler
so they survive ClientRouter navigations. Set sessionReplay.sampleRate to
import.meta.env.DEV ? 1 : 0.01 and tell me how to turn it off. Add the
page's Project Created example in src/components/CreateProjectForm.astro
and create src/pages/projects/new.astro rendering it if it doesn't exist.
Invent no other event names. Tell me whether any page renders on demand;
if none does, signed-in identity won't work until one does. Don't hardcode
the key. Report every file you changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay), already on from step 2, lets you watch the session where `Project Created` fired with `template: kanban`.
- [Heatmaps](https://amplitude.com/heatmaps) show where people click on `/projects/new` before they create a project, built from those same session replays.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in the same package, can test a new template picker with `Project Created` as the metric.
- [Guides and surveys](https://amplitude.com/guides-and-surveys), also bundled, can prompt users who viewed `/projects/new` but never fired `Project Created`.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add web analytics to an Astro siteRead traffic, campaigns, and landing pages for your content pages, ClientRouter or not.](/build/astro-web-analytics)[Web frameworksHow to add session replay to an Astro siteTune the sample rate and mask sensitive content before you record real users.](/build/astro-session-replay)[Use caseHow to stitch client and server events to one userKeep browser events and your API routes' server events on one profile through sign-up.](/build/stitch-client-server-events)

Reference docs

[Amplitude Quickstart →](https://amplitude.com/docs/get-started/amplitude-quickstart)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Browser SDK 2 →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2)[Configure session replay sampling →](https://amplitude.com/docs/session-replay/configure-sampling)[Debug analytics →](https://amplitude.com/docs/analytics/debug-analytics)[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-product-analytics.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)
