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 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 to get your project API key. Your session replay capture quota depends on your plan; compare plans.

1. Install the SDK and add the key

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

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

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.

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
// -- .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
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, already on from step 2, lets you watch the session where Project Created fired with template: kanban.
  • Heatmaps show where people click on /projects/new before they create a project, built from those same session replays.
  • Feature Experiment, bundled in the same package, can test a new template picker with Project Created as the metric.
  • 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

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

Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.