---
title: "How to add guides and surveys to an Astro site"
description: "Show Amplitude guides and surveys on an Astro site: guide links through the ClientRouter, guide UI kept across swaps, stable pins, a survey button."
canonical: "https://amplitude.com/en-us/build/astro-guides-surveys"
---

# How to add guides and surveys to an Astro site

Show Amplitude guides and surveys on an Astro site: guide links through the ClientRouter, guide UI kept across swaps, stable pins, a survey button.

Source: https://amplitude.com/en-us/build/astro-guides-surveys

---

# How to add guides and surveys to an Astro site

Turn on guides and surveys in an Astro site, send guide links through Astro's navigate(), keep guide UI on screen when \<ClientRouter /> replaces the page, anchor a pin, and open a survey from your own button. About 20 minutes, all copy-paste.

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

Chris Tam · Last updated 19 October 2026

Astro sites are mostly static HTML with a little JavaScript where you need it. [Guides and surveys](https://amplitude.com/guides-and-surveys) add tours, announcements, and feedback prompts to those pages, and you edit them in Amplitude instead of shipping a new build.

In this tutorial, you'll turn on guides and surveys, route guide links through Astro's router, keep guide UI across page swaps, anchor a pin, and open a survey. It builds on [the Astro product analytics page](/build/astro-product-analytics).

## What you'll build

An Astro site where guides and surveys boot once per page load, in the browser, with the same user and device ID as your analytics. Guide links set to Use router go through Astro's `navigate()` when the page uses `<ClientRouter />`, a guide button runs an `open_new_project` callback in your code, guide UI stays on screen when the router replaces `<body>`, a pin anchors to `[data-guide="new-project-button"]`, and a "Give feedback" button opens the survey with the key `product-feedback`. By the end you'll see the SDK report a booted user, `Guide Viewed` in your test user's event stream, and your answer on the survey's Responses tab.

## Prerequisites

- The setup from [How to add product analytics to an Astro site](/build/astro-product-analytics): `@amplitude/unified` installed, `PUBLIC_AMPLITUDE_API_KEY` in `.env`, `initAll` and the `amplitudeReady` export in `src/scripts/amplitude.ts`, and `<Amplitude />` rendered first in `<body>` in `src/layouts/Layout.astro`.
- From the same page: `src/components/SignOutButton.astro`, the `src/lib/auth.ts` stub or your own auth helper, and the `/projects/new` page. The guide's callback goes to `/projects/new`.
- Either output mode works, with or without `<ClientRouter />`. Guides and surveys run only in the browser, so prerendered pages show them too.
- Your Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General. Guides and surveys must use the same key as your analytics.

No Amplitude account yet? A free account is enough for this tutorial: the Free plan includes one live guide and one live survey, which is what this page builds. [Sign up for free](https://app.amplitude.com/signup) to get your project API key, and [compare plans](https://amplitude.com/pricing) when you need more.

## 1. Turn guides and surveys on

The [Quickstart](https://amplitude.com/docs/get-started/amplitude-quickstart) installs `@amplitude/unified` for browser apps that use npm, and the Browser Unified SDK includes guides and surveys and initializes them by default. If you followed the [product analytics page](/build/astro-product-analytics), it's already installed. Otherwise install it, then add the key to `.env` as that page's step 1 shows.

Terminal

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

The `initAll` call in `src/scripts/amplitude.ts` already starts guides and surveys with the analytics user, so don't call `engagement.init()` or `engagement.boot()` yourself; the docs say calling boot more than once causes unexpected behavior, especially for guides and surveys set to appear immediately. Don't pass `engagement: { skip: true }` either, which stops guides and surveys from loading at all.

This page adds one `engagement` block to the `initAll` options, next to `analytics` and `sessionReplay`. It makes the SDK write the styles for guide and survey UI as text inside `<style>` tags, which step 3 needs to keep that UI styled across `<ClientRouter />` navigations. It changes nothing on a site without the router.

src/scripts/amplitude.ts

```typescript
// src/scripts/amplitude.ts (add this block to the initAll options, beside analytics and sessionReplay; keep the rest)
// 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 optional webExperiment plugin lines below the
// amplitudeReady export (/build/astro-web-experiment). Import this module only
// from <script> tags, never from component frontmatter.
        engagement: {
          // Write guide and survey CSS as text so it survives ClientRouter swaps.
          options: { renderCssInDom: true },
        },
```

If you added masking on the session replay page, it stays in that file and applies to sessions where a guide or survey shows. Set `sampleRate` to `0` there to turn session replay off.

Then turn the product on in Amplitude. The [Guides and Surveys Web SDK docs](https://amplitude.com/docs/sdks/guides-and-surveys/sdk) say to enable guides and surveys in your project settings before anything can display, and the [Get started page](https://amplitude.com/docs/guides-and-surveys/get-started#enable-or-disable-apps) says the project-level apps setting has to include web so it appears as a target when you create a guide or survey. Turn on both in your project settings.

### If your site sends a Content Security Policy

Skip this if your site doesn't send a Content-Security-Policy; a new Astro project sends none. If yours does, follow the [guides and surveys CSP guidance](https://amplitude.com/docs/sdks/guides-and-surveys/sdk#content-security-policy-csp) and merge its hosts into the directives you already have: `https://*.amplitude.com` in `script-src`, `connect-src`, `img-src`, `media-src`, and `style-src`, and inline styles allowed in `style-src`, which a hash-only policy blocks. Add `worker-src 'self' blob:` too, which the [session replay CSP guidance](https://amplitude.com/docs/sdks/session-replay/session-replay-plugin#content-security-policy-csp) lists for its web worker; without it, the console reports blocked workers and the SDKs fall back to the main thread. Astro's `security.csp` option has no nonce to hand the SDK and doesn't support `<ClientRouter />`, so a site that uses the router needs its policy from your host instead.

If your site sends `Cross-Origin-Opener-Policy: same-origin`, Preview mode can't open your page. Set it to `same-origin-allow-popups` instead.

## 2. Connect guide buttons to Astro's router

A guide's Visit link action loads the page normally (Same tab or New tab) unless you give the SDK a router and set the link's URL behavior to Use router. A Run callback action does nothing unless your code has registered a function with that name. Register both once, from a client module, after `initAll` has finished.

First, a typed accessor. `window.engagement` isn't in TypeScript's `Window` type, so list the methods these pages call:

src/scripts/engagement.ts

```typescript
// The window.engagement methods these pages call, as the Guides and
// Surveys Web SDK docs define them. Import only from <script> tags.
type Engagement = {
  setRouter(fn: (url: string) => void): void;
  addCallback(name: string, fn: () => void): void;
  gs: {
    show(key: string, stepIndex?: number): void;
    closeAll(): void;
  };
  _debugStatus?: () => void;
};

export function getEngagement(): Engagement | undefined {
  // No window during the build or a server render.
  if (typeof window === "undefined") return undefined;
  return (window as unknown as { engagement?: Engagement }).engagement;
}
```

Then the bridge. It waits for `amplitudeReady` from `src/scripts/amplitude.ts`, then registers the router and the callback.

src/scripts/engagement-bridge.ts

```typescript
// Loaded once from Amplitude.astro: once per full page load, or once per
// visit with <ClientRouter />.
import { navigate, transitionEnabledOnThisPage } from "astro:transitions/client";
import { amplitudeReady } from "./amplitude";
import { getEngagement } from "./engagement";

function go(url: string) {
  const target = new URL(url, window.location.href);

  // Other sites, and every page without <ClientRouter />, load normally.
  if (target.origin !== window.location.origin || !transitionEnabledOnThisPage()) {
    window.location.assign(target.href);
    return;
  }

  navigate(target.pathname + target.search + target.hash);
}

void amplitudeReady.then(() => {
  const engagement = getEngagement();
  if (!engagement) {
    console.warn("window.engagement not found; guide links will reload the page.");
    return;
  }

  // Guide links set to "Use router" call this instead of loading the page.
  engagement.setRouter(go);

  // Guide buttons with "Run callback" and the name open_new_project.
  engagement.addCallback("open_new_project", () => go("/projects/new"));
});
```

You'll load it from `Amplitude.astro` at the end of step 3. Then, in the builder, set the URL behavior on every Visit link action to Use router, and enter `open_new_project` as the name on any Run callback action. The docs are explicit: Same tab and New tab ignore the router you set, and the callback name must match the string passed to `addCallback`.

**Why.** Astro has no app-level component that mounts once, but a bundled module does the same job. Astro runs a module once per page load, and with `<ClientRouter />` it runs bundled scripts only once per visit, so `setRouter` and `addCallback` register exactly once either way. Don't move this into an `astro:page-load` listener, which runs after every navigation. `navigate()` from `astro:transitions/client` performs a ClientRouter navigation, so it only makes sense on a page that renders `<ClientRouter />`; `transitionEnabledOnThisPage()` checks that at click time and falls back to a normal page load. `amplitudeReady` resolves even when the key is missing or `initAll` fails, so nothing waits forever. If the key is missing, there's no `window.engagement`, and the console warning tells you why guide links reload.

## 3. Keep guide UI through ClientRouter swaps

Add this step's file whether or not your layout renders `<ClientRouter />`; without the router it never runs. Without `<ClientRouter />`, every link is a full page load, `initAll` runs again on the new page, and guides and surveys start fresh with the same user and device ID, which the SDK keeps in browser storage.

With `<ClientRouter />`, the router replaces the whole `<body>` on each navigation, and only elements in your Astro markup can opt out with `transition:persist`. Guides and surveys render inside a single `#engagement-wrapper` element. That element isn't in your Astro markup, so you can't mark it with `transition:persist`. If it sits inside `<body>`, a survey that's open when someone clicks a link loses it in the swap. The SDK also adds `<style>` tags to `<head>` when it loads, and the router removes every head element the next page doesn't have, so guide and survey UI shown after the first navigation would render without its styles.

Wrap the router's swap so the element and those styles move into the new page:

src/scripts/engagement-swap.ts

```typescript
// Keeps guides and surveys, and the styles the SDK adds to <head>, on screen
// across <ClientRouter /> navigations. Without <ClientRouter />,
// astro:before-swap never fires.
import type { TransitionBeforeSwapEvent } from "astro:transitions/client";

// The <style> tags this page shipped with. Any other <style> in <head> is the SDK's.
let pageStyles = new Set(document.head.querySelectorAll("style"));

function keepEngagementWrapper(event: TransitionBeforeSwapEvent) {
  const defaultSwap = event.swap;

  event.swap = () => {
    // Guides and surveys render inside #engagement-wrapper.
    const wrapper = document.getElementById("engagement-wrapper");
    const sdkStyles = [...document.head.querySelectorAll("style")].filter(
      (style) => !pageStyles.has(style),
    );

    defaultSwap();

    // The new page doesn't contain them. Put them back before it paints.
    if (wrapper && !wrapper.isConnected) {
      document.body.append(wrapper);
    }
    document.head.append(...sdkStyles.filter((style) => !style.isConnected));
    pageStyles = new Set(
      [...document.head.querySelectorAll("style")].filter((style) => !sdkStyles.includes(style)),
    );
  };
}

document.addEventListener("astro:before-swap", keepEngagementWrapper);
```

Load both modules from a second `<script>` in `Amplitude.astro`, after the identity element's script. Every page that uses your layout now runs them.

src/components/Amplitude.astro

```html
<!-- src/components/Amplitude.astro (add this second <script> after the existing one; keep the rest) -->
<!-- Keep lines other Astro pages added here: the <amplitude-identity> element and its script (/build/astro-product-analytics). -->
<script>
  import "../scripts/engagement-bridge";
  import "../scripts/engagement-swap";
</script>
```

**Why.** `astro:before-swap` fires inside the view transition, while the visitor still sees the old page, and `event.swap` is the default swap that diffs `<head>`, moves `transition:persist` elements, and replaces `<body>`. Calling it first and appending afterwards keeps Astro's own behavior intact, and the element and styles are back before anything paints. The handler treats every `<head>` `<style>` the current page didn't ship with as the SDK's, puts back only the ones the swap removed, and then records the new page's own styles, so one page's styles never follow the visitor to the next. Moving the wrapper takes its `<style>` tags out of the document and back in, and the browser rebuilds each one from its text when that happens; that's why step 1 sets `renderCssInDom`, so the text holds the rules. The `isConnected` checks mean the code does nothing if the element lives outside `<body>` or a future SDK version restores it on its own. The listener is on `document`, which the router never replaces, and the module runs once per visit, so it registers once. Pins are different: a pin attaches to an element on the page, and that element leaves with the old `<body>`. Point pins at elements that exist on the page where the step shows, and use the guide's page targeting for each step.

## 4. Keep identity stable so guides show once

Amplitude remembers which guides and surveys a user has seen, completed, or dismissed as part of that user's history. The docs warn that if a user's identity changes between visits, Amplitude treats them as a new user and shows the experience again. `reset()` issues a new device ID, so calling it on every anonymous page would show an anonymous visitor the same welcome guide on every visit.

The identity element from the product analytics page already returns early when there's no user, so keep it as it is. When it sets a user ID after sign-in, the SDK refreshes targeting on its own, because it refreshes whenever the user or user properties change. Reset only from the sign-out form, and close anything still on screen first:

src/components/SignOutButton.astro

```html
---
// Keep lines other Astro pages added here: 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";
  import { getEngagement } from "../scripts/engagement";

  class SignOutButton extends HTMLElement {
    connectedCallback() {
      this.querySelector("form")?.addEventListener("submit", () => {
        getEngagement()?.gs.closeAll(); // close anything still on screen
        amplitude.reset(); // clears the user ID and issues a new device ID
        amplitude.experiment()?.clear(); // from /build/astro-feature-flags; harmless without flags
        try {
          sessionStorage.removeItem("amplitude-identity");
        } catch {}
      });
    }

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

Point `action` at your own sign-out route. On sign-out the console can show `Cannot unregister the default instance` and `Engagement SDK not booted. Ignoring identity change.` as warnings; they're expected, and the SDK ends up with the new device ID and no user ID. This page adds the `getEngagement` import and `closeAll()` before `reset()`. The `experiment()?.clear()` line belongs to the feature flags page; it's in the block so the file reads the same on both pages, and it does nothing if you don't use flags. If you don't have a sign-out route yet, skip this file until you do.

**Why.** With `<ClientRouter />`, the sign-out form submits through the router and the next page swaps in without a reload, so a survey left open would follow the visitor into the signed-out page through step 3's handler. `closeAll()` clears it first. Without the router, the form submission is a full page load and `closeAll()` simply runs before it. The SDK also has `shutdown()`, but after it guides and surveys stay off until something calls `boot()` again, which this page leaves to `initAll`. `closeAll()` clears the screen and keeps them running for the next visitor.

## 5. Give pins a target that survives a rebuild

Pins, tooltips, and card embeds attach to an element by CSS selector, and the docs recommend data attributes added specifically for guide targeting. The visual picker looks for the most stable selector it can find, but Astro's scoped styles add a generated `data-astro-cid-` attribute that changes when the component changes, so give the picker something better to find. Tag the elements you plan to point at in your existing home page, rather than replacing the file:

src/pages/index.astro

```html
<!-- src/pages/index.astro (add to your existing home page markup inside <Layout>) -->
<!-- Keep lines other Astro pages added here: data-exp attributes (/build/astro-web-experiment). -->
<!-- Add data-guide to the elements a guide points at, for example: -->
<a href="/projects/new" data-guide="new-project-button">Create a project</a>
```

If your home page has no link to `/projects/new` yet, add this one. If the link already carries `data-exp="home-cta"` from the Web Experiment page, add `data-guide` next to it; one element can carry both. Put the attribute on HTML elements in your `.astro` markup. On your own Astro components, `data-guide` is just a prop, and it only reaches the HTML if the component spreads its props onto an element.

In the guide builder, add a Pin step and enter `[data-guide="new-project-button"]` as its CSS selector instead of the generated one.

**Why.** The docs say that if the target element isn't in the DOM when the step renders, the pin doesn't display, and the SDK doesn't move it to another position. Static `.astro` markup is in the HTML before any script runs, so it's always there. Elements an island renders with `client:visible`, `client:idle`, or `client:only` can appear after the guide triggers. For those, set the trigger to When element appears with the same selector, so the guide fires when the element renders rather than when the page loads. On a later step in a tour, turn on Skip if target not found, which moves on if the target doesn't appear within 250 ms.

## 6. Open a survey from your own button

When a survey should open because someone clicked something, the docs recommend setting its trigger to None in the builder and calling `show` from the click handler. Use a custom element, so the handler comes back after every ClientRouter navigation:

src/components/FeedbackButton.astro

```html
<feedback-button>
  <button type="button">Give feedback</button>
</feedback-button>

<script>
  import { amplitudeReady } from "../scripts/amplitude";
  import { getEngagement } from "../scripts/engagement";

  class FeedbackButton extends HTMLElement {
    connectedCallback() {
      this.querySelector("button")?.addEventListener("click", () => {
        // Waits for initAll, so an early click opens the survey once it's ready.
        void amplitudeReady.then(() => {
          getEngagement()?.gs.show("product-feedback");
        });
    }

  customElements.define("feedback-button", FeedbackButton);
</script>
```

Render it in your shared layout, after `<Amplitude />`, so every page has it. If your layout has no frontmatter yet (the `create astro` starter's doesn't), add the `---` fences at the top of the file.

src/layouts/Layout.astro

```html
---
// src/layouts/Layout.astro (frontmatter: add this import; keep the rest)
// Keep lines other Astro pages added here: the Amplitude import and <Amplitude />
// as the first thing in <body> (/build/astro-product-analytics); 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).
import FeedbackButton from "../components/FeedbackButton.astro";
---

<!-- In <body>, after <Amplitude /> and before <slot />: -->
<header>
  <FeedbackButton />
</header>
```

Replace `product-feedback` with your survey's key from the builder. `show` ignores targeting rules and limits but still respects page targeting, so the survey's Where settings must allow every page where the button renders. Because the button lives in the layout, that's every page on the site. Using a React, Vue, or Svelte island instead? Call the same two lines from the island's click handler, importing from `src/scripts/amplitude.ts` and `src/scripts/engagement.ts`.

If you'd rather use the On event tracked trigger, the event has to fire in the browser. `Project Created`, tracked with `amplitude.track` from the custom element on the product analytics page, works. An event your endpoint or middleware sends with the Node.js SDK never reaches the guides and surveys SDK; call `show` from the browser after that request resolves instead.

**MCP.** Working in Cursor or Claude Code? [Amplitude's MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp) includes `list_guides_surveys`, which lists your project's guides and surveys, and `get_guide_or_survey`, which returns one by ID with its page targeting rules and trigger configuration. Your agent can confirm the survey exists, that its trigger is None, and that its page targeting allows every page where the button renders. Copy the key itself from the builder.

## Verify it worked

Run `npm run dev` and sign in as a test user on a page rendered on demand, or use your device ID if you're on the auth stub. In Amplitude, create a guide with a Pin step on `[data-guide="new-project-button"]`, set to Open by default, with a button whose action is Run callback with `open_new_project`, and a survey with the key `product-feedback` and the trigger None. Add your user ID or device ID under Test users and set both to Testing, or click Preview and enter your local URL (the dev server's default is `http://localhost:4321`). Then, in the site, open the pin, click its button, and submit the survey from Give feedback. Check in this order:

1. **Network tab.** Filter for `amplitude.com`. You should see requests to `cdn.amplitude.com`, where the SDK loads its assets, and `gs.amplitude.com`, the default guides and surveys host, plus the analytics SDK's POST to `api2.amplitude.com/2/httpapi` returning 200. EU projects use `cdn.eu.amplitude.com`, `gs.eu.amplitude.com`, and `api.eu.amplitude.com` instead. None of them start until the module runs in the browser; the build and the server render send nothing.
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 or your device ID. Within ten seconds to a minute the event stream shows `Guide Viewed` for the pin. A pin that isn't open by default skips `Guide Viewed` when someone clicks its target. Guides and surveys events don't count toward your event volume. Your answer appears on the survey's Responses tab; turn off Ignore test/preview data to see answers from test users.
4. **Browser console.** `window.engagement` returns an object, not `undefined`. Run `window.engagement._debugStatus()`. It prints an object rather than returning one: expand `user` to check your ID, `apiKey` should be set, `stateInitialized` and `decideSuccessful` should be `true`, and `num_guides_surveys` should be non-zero on the home page.
5. **Amplitude Chrome extension.** The Guides & Surveys tab shows four setup checks: Installed, Initialized, Analytics Connected, and Booted. Let the page finish loading first, since the tab doesn't update live.
6. **In the site.** The pin appeared on Create a project, its button opened `/projects/new`, and Give feedback opened the survey. With `<ClientRouter />`, the request for `/projects/new` shows as a fetch, not a document load.
7. **Across a ClientRouter navigation.** Skip this without `<ClientRouter />`. Open the survey, then click any internal link before you answer it. The survey stays on screen on the new page and looks the same as before you clicked, with its layout, colors, and buttons styled. `document.getElementById("engagement-wrapper")?.isConnected` returns `true` in the console.

Check a production build too: run `npm run build` and `npm run preview`, then repeat steps 1, 6, and 7 at `http://localhost:4321`. That's the build your host serves, with your scripts bundled the way they ship.

A guide you already completed or dismissed won't show again. Open your user profile, go to the Guides or Surveys tab, and select Clear history for that experience, or click Reset User History in the preview bar. Preview mode has a known issue with multi-page guides and surveys in multi-page apps, which is what an Astro site without `<ClientRouter />` is; use Testing status to check a tour that spans pages.

## Troubleshooting

### Guides and surveys never start

Run `window.engagement` in the console. `undefined` means `initAll` never ran: confirm the page renders `<Amplitude />` and that `PUBLIC_AMPLITUDE_API_KEY` is set (the dev server reads `.env` only at startup, so restart it). If it returns an object but `window.engagement._debugStatus()` prints nothing, the SDK bundle never loaded: check that nothing passes `engagement: { skip: true }` and that an ad blocker or your Content Security Policy isn't blocking `cdn.amplitude.com`.

### "window is not defined" during the build

Something imports `src/scripts/engagement.ts`, `src/scripts/amplitude.ts`, or `@amplitude/unified` in component frontmatter, middleware, or an endpoint. Those run on the server or at build time. Import them only inside `<script>` tags and island event handlers.

### A guide set to Immediately shows twice, or not at all

Something calls `engagement.boot()` or `engagement.init()` on top of `initAll`, often in an `astro:page-load` listener that runs on every navigation. Delete those calls.

### A survey disappears after a ClientRouter navigation

The swap handler isn't running. Check that `Amplitude.astro`'s second `<script>` imports `src/scripts/engagement-swap.ts` and that the script tag has no attributes, since a `<script>` with any attribute isn't bundled and can't import modules. After a navigation, `document.getElementById("engagement-wrapper")?.isConnected` should be `true`.

### A guide or survey shows unstyled after a ClientRouter navigation

Plain text, default buttons, and no card layout mean the SDK's styles didn't survive the swap. Check that `src/scripts/engagement-swap.ts` is the version in step 3, which also restores the `<head>` styles, and that the `engagement` block with `renderCssInDom: true` from step 1 is inside the `initAll` options. Compare `document.head.querySelectorAll("style").length` before and after a navigation between two pages with the same styles; it shouldn't drop.

### Give feedback does nothing and the console says "Nudge with key: product-feedback not found"

The SDK has no survey with that key for this user. Check that the key in `FeedbackButton.astro` matches the builder, that the survey is live or set to Testing with your user or device ID under Test users, and that it's in the same project as your API key.

### Anonymous visitors see the same welcome guide on every visit

`reset()` runs while the user is signed out and issues a new device ID, so every visit looks like a new user. Keep the identity element's early return and call `reset()` only in the sign-out form's submit handler.

### The pin never appears, but the survey button works

The target element isn't in the DOM when the step renders, or the selector no longer matches. Check `document.querySelector('[data-guide="new-project-button"]')` in the console, and for elements an island renders late, use the When element appears trigger.

### Guide links load the whole page

That's expected on a page without `<ClientRouter />`. With it, check that the link's URL behavior is Use router and run `Object.keys(window.engagement._.callbacks)` in the console; `engagement-router` should be in the list once `setRouter` has run.

### A Run callback button does nothing

The name in the builder doesn't match the `addCallback` string. Run `Object.keys(window.engagement._.callbacks)` in the console; `open_new_project` should be in the list, next to internal entries like `engagement-router`.

### A guide shows on /projects/new but not on /projects/new/

Where rules match the exact URL, including trailing slashes. With Astro's default `trailingSlash: 'ignore'`, the dev server and pages rendered on demand answer on both, so write the rule to match the URLs your links produce, set `trailingSlash` to `'always'` or `'never'`, or use a URL starts with rule.

### Preview never opens on your page

Your site sends `Cross-Origin-Opener-Policy: same-origin`, which blocks the messages Preview mode depends on. Set it to `same-origin-allow-popups`, as in step 1.

## Full code reference

Every file this tutorial adds or changes, in one block: the version to hand to an agent. 9 files. Each shared file starts with a comment naming the lines other Astro pages add there; merge into your file and keep those lines. In `src/scripts/amplitude.ts`, add the `engagement` block to the `initAll` options; in `src/components/Amplitude.astro`, add the second `<script>`; in `src/layouts/Layout.astro`, add the import and the header; in `src/pages/index.astro`, add the attribute to your existing link.

9 files

```html
// -- src/scripts/amplitude.ts (add this block to the initAll options, beside analytics and sessionReplay; keep the rest) --
// 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 optional webExperiment plugin lines below the
// amplitudeReady export (/build/astro-web-experiment). Import this module only
// from <script> tags, never from component frontmatter.
        engagement: {
          // Write guide and survey CSS as text so it survives ClientRouter swaps.
          options: { renderCssInDom: true },
        },

// -- src/scripts/engagement.ts --
// The window.engagement methods these pages call, as the Guides and
// Surveys Web SDK docs define them. Import only from <script> tags.
type Engagement = {
  setRouter(fn: (url: string) => void): void;
  addCallback(name: string, fn: () => void): void;
  gs: {
    show(key: string, stepIndex?: number): void;
    closeAll(): void;
  };
  _debugStatus?: () => void;
};

export function getEngagement(): Engagement | undefined {
  // No window during the build or a server render.
  if (typeof window === "undefined") return undefined;
  return (window as unknown as { engagement?: Engagement }).engagement;
}

// -- src/scripts/engagement-bridge.ts --
// Loaded once from Amplitude.astro: once per full page load, or once per
// visit with <ClientRouter />.
import { navigate, transitionEnabledOnThisPage } from "astro:transitions/client";
import { amplitudeReady } from "./amplitude";
import { getEngagement } from "./engagement";

function go(url: string) {
  const target = new URL(url, window.location.href);

  // Other sites, and every page without <ClientRouter />, load normally.
  if (target.origin !== window.location.origin || !transitionEnabledOnThisPage()) {
    window.location.assign(target.href);
    return;
  }

  navigate(target.pathname + target.search + target.hash);
}

void amplitudeReady.then(() => {
  const engagement = getEngagement();
  if (!engagement) {
    console.warn("window.engagement not found; guide links will reload the page.");
    return;
  }

  // Guide links set to "Use router" call this instead of loading the page.
  engagement.setRouter(go);

  // Guide buttons with "Run callback" and the name open_new_project.
  engagement.addCallback("open_new_project", () => go("/projects/new"));
});

// -- src/scripts/engagement-swap.ts --
// Keeps guides and surveys, and the styles the SDK adds to <head>, on screen
// across <ClientRouter /> navigations. Without <ClientRouter />,
// astro:before-swap never fires.
import type { TransitionBeforeSwapEvent } from "astro:transitions/client";

// The <style> tags this page shipped with. Any other <style> in <head> is the SDK's.
let pageStyles = new Set(document.head.querySelectorAll("style"));

function keepEngagementWrapper(event: TransitionBeforeSwapEvent) {
  const defaultSwap = event.swap;

  event.swap = () => {
    // Guides and surveys render inside #engagement-wrapper.
    const wrapper = document.getElementById("engagement-wrapper");
    const sdkStyles = [...document.head.querySelectorAll("style")].filter(
      (style) => !pageStyles.has(style),
    );

    defaultSwap();

    // The new page doesn't contain them. Put them back before it paints.
    if (wrapper && !wrapper.isConnected) {
      document.body.append(wrapper);
    }
    document.head.append(...sdkStyles.filter((style) => !style.isConnected));
    pageStyles = new Set(
      [...document.head.querySelectorAll("style")].filter((style) => !sdkStyles.includes(style)),
    );
  };
}

document.addEventListener("astro:before-swap", keepEngagementWrapper);

// -- src/components/Amplitude.astro (add this second <script> after the existing one; keep the rest) --
<!-- Keep lines other Astro pages added here: the <amplitude-identity> element and its script (/build/astro-product-analytics). -->
<script>
  import "../scripts/engagement-bridge";
  import "../scripts/engagement-swap";
</script>

// -- src/components/SignOutButton.astro --
---
// Keep lines other Astro pages added here: 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";
  import { getEngagement } from "../scripts/engagement";

  class SignOutButton extends HTMLElement {
    connectedCallback() {
      this.querySelector("form")?.addEventListener("submit", () => {
        getEngagement()?.gs.closeAll(); // close anything still on screen
        amplitude.reset(); // clears the user ID and issues a new device ID
        amplitude.experiment()?.clear(); // from /build/astro-feature-flags; harmless without flags
        try {
          sessionStorage.removeItem("amplitude-identity");
        } catch {}
      });
    }

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

// -- src/pages/index.astro (add to your existing home page markup inside <Layout>) --
<!-- Keep lines other Astro pages added here: data-exp attributes (/build/astro-web-experiment). -->
<!-- Add data-guide to the elements a guide points at, for example: -->
<a href="/projects/new" data-guide="new-project-button">Create a project</a>

// -- src/components/FeedbackButton.astro --
<feedback-button>
  <button type="button">Give feedback</button>
</feedback-button>

<script>
  import { amplitudeReady } from "../scripts/amplitude";
  import { getEngagement } from "../scripts/engagement";

  class FeedbackButton extends HTMLElement {
    connectedCallback() {
      this.querySelector("button")?.addEventListener("click", () => {
        // Waits for initAll, so an early click opens the survey once it's ready.
        void amplitudeReady.then(() => {
          getEngagement()?.gs.show("product-feedback");
        });
    }

  customElements.define("feedback-button", FeedbackButton);
</script>

// -- src/layouts/Layout.astro (frontmatter: add this import; keep the rest) --
---
// Keep lines other Astro pages added here: the Amplitude import and <Amplitude />
// as the first thing in <body> (/build/astro-product-analytics); 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).
import FeedbackButton from "../components/FeedbackButton.astro";
---

<!-- In <body>, after <Amplitude /> and before <slot />: -->
<header>
  <FeedbackButton />
</header>

// Unchanged from earlier pages; keep your versions: .env, src/middleware.ts, src/env.d.ts, src/lib/auth.ts, src/components/CreateProjectForm.astro, src/pages/projects/new.astro.
```

## 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 already has its setup: @amplitude/unified,
src/scripts/amplitude.ts with initAll and the amplitudeReady export, and
<Amplitude /> first in <body> in src/layouts/Layout.astro. Create its
src/lib/auth.ts stub and src/pages/projects/new.astro if they're missing.
If the site has no sign-out route, skip SignOutButton.astro and tell me. Then add Amplitude guides
and surveys by following https://amplitude.com/build/astro-guides-surveys
exactly. Merge into existing shared files and never delete lines another
Amplitude Build page added; each file's first comment on that page names
them. In src/scripts/amplitude.ts, add only engagement: { options: {
renderCssInDom: true } } to the initAll options, beside analytics and
sessionReplay. Never call engagement.init() or engagement.boot(), and
don't pass engagement: { skip: true }. Every
client script imports the SDK from src/scripts/amplitude.ts, never from
@amplitude/unified, and never from component frontmatter, middleware, or
endpoints. Add src/scripts/engagement.ts with a typed getEngagement().
Add src/scripts/engagement-bridge.ts: after amplitudeReady resolves,
register setRouter (same-origin URLs through navigate() from
astro:transitions/client when transitionEnabledOnThisPage() is true,
everything else through window.location.assign) and an open_new_project
callback that goes to /projects/new. Add src/scripts/engagement-swap.ts:
an astro:before-swap listener that wraps event.swap, saves
#engagement-wrapper and every <head> <style> the current page didn't ship
with, calls the default swap, appends the wrapper to the new body if it's
disconnected, re-appends the disconnected styles to <head>, and records
the new page's own styles. Import both modules from a second bare
<script> in src/components/Amplitude.astro. In SignOutButton.astro's
submit handler, call getEngagement()?.gs.closeAll(), then
amplitude.reset(), then amplitude.experiment()?.clear(), and keep the
sessionStorage removal. Add data-guide="new-project-button" to a link to
/projects/new in src/pages/index.astro without replacing the file. Create
src/components/FeedbackButton.astro as a custom element that waits for
amplitudeReady and calls gs.show('product-feedback'), and render it in a
<header> after <Amplitude /> in the layout. If the site sends a CSP, add
https://*.amplitude.com to script-src, connect-src, img-src, media-src,
and style-src, allow inline styles, and add worker-src 'self' blob:; if it sends COOP, use
same-origin-allow-popups. Don't hardcode the key and don't invent survey
or callback names. Tell me whether the layout renders <ClientRouter />,
and report every file you changed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay), on at the sample rate from the product analytics page, lets you watch what visitors did right after they submitted `product-feedback`.
- [Heatmaps](https://amplitude.com/heatmaps) (Plus, Growth or Enterprise) are built from session replays, so they show whether people click `new-project-button` more after the pin goes live.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in the same package, can gate the feature a guide announces, so the flag and the guide reach the same users.
- [Product analytics](https://amplitude.com/amplitude-analytics) charts `Guide Viewed` next to `Project Created`, so you can see whether the pin moves project creation.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add feature flags to an Astro siteGate the feature a guide announces behind a flag, evaluated in the browser or on the server.](/build/astro-feature-flags)[Web frameworksHow to add session replay to an Astro siteRecord the sessions around a guide or survey, and mask what they shouldn't show.](/build/astro-session-replay)[Web frameworksHow to add heatmaps to an Astro siteSee where people click before and after a pin goes live.](/build/astro-heatmaps)

Reference docs

[Guides and Surveys Web SDK →](https://amplitude.com/docs/sdks/guides-and-surveys/sdk)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Testing and publishing guides and surveys →](https://amplitude.com/docs/guides-and-surveys/testing)[Troubleshoot guides and surveys →](https://amplitude.com/docs/guides-and-surveys/troubleshooting/guide-or-survey-not-showing)[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-guides-surveys.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)
