---
title: "How to add guides and surveys to a Next.js app"
description: "Show Amplitude guides and surveys in a Next.js App Router app: client-only init, no double boot, router-aware links, and identity that targeting can use."
canonical: "https://amplitude.com/en-us/build/nextjs-guides-surveys"
---

# How to add guides and surveys to a Next.js app

Show Amplitude guides and surveys in a Next.js App Router app: client-only init, no double boot, router-aware links, and identity that targeting can use.

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

---

# How to add guides and surveys to a Next.js app

Install the Amplitude Unified SDK in a Next.js App Router project, let it boot guides and surveys once on the client, route guide links through the Next.js router, identify signed-in users so targeting works, and open a survey from your own button. About 20 minutes, all copy-paste.

- Next.js 13+
- App Router
- Unified SDK 1.x
- 20 min

Chris Tam · Last updated 25 September 2026

Next.js is one of the most popular frameworks for building web apps. [Guides and surveys](https://amplitude.com/guides-and-surveys) let you onboard users and ask them for feedback inside your app, without shipping a deploy for each one.

In this tutorial, you'll add guides and surveys to a Next.js App Router app, target signed-in users, and open a survey from your own code.

## What you'll build

A Next.js app where guides and surveys boot once with the same identity as Analytics, sets `user_id` and a `plan` user property you can target on, sends guide link clicks through `router.push`, and opens a survey with the key `product-feedback` from a "Give feedback" button. By the end you'll see the SDK report a booted user and a live guide on your page.

## Prerequisites

- A Next.js 13+ project using the App Router. `npx create-next-app@latest` with TypeScript and the default `@/*` import alias is fine.
- Node.js 16.8 or later, the floor the Amplitude Next.js guide lists.
- An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General. Guides and surveys and Analytics must use the same key.
- A way to know who's signed in on the client (your auth hook or session provider).

No Amplitude account yet? A free account is enough for this tutorial; nothing here needs a paid plan. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

## 1. Install the SDK and turn on guides and surveys

[Amplitude's Next.js guide](https://amplitude.com/docs/sdks/frameworks/nextjs-installation-guide) recommends `@amplitude/unified` for new projects, and it includes guides and surveys by default.

Terminal

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

.env.local

```sh
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
```

Next.js only exposes `NEXT_PUBLIC_` variables to the browser. The project API key is public and its scope is limited to the minimum needed to ingest data, so it's safe there, but keep it out of source.

Then, in Amplitude, enable guides and surveys in your project settings and enable the web app, so web appears as a target when you create a guide or survey. Nothing displays until both are on.

## 2. Initialize once, on the client only

Put initialization in one module, behind a `typeof window` check and a `started` flag, and export a promise that resolves when the SDK is ready. Other components wait on that promise instead of guessing.

lib/amplitude.ts

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

let started = false;
let markReady: () => void = () => {};
const ready = new Promise<void>((resolve) => {
  markReady = resolve;
});

/** Resolves once Amplitude, including Guides and Surveys, has initialized. */
export function amplitudeReady() {
  return ready;
}

export async function initAmplitude(options: { nonce?: string } = {}) {
  if (typeof window === 'undefined' || started) return ready;

  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
    return ready;
  }

  started = true;

  await amplitude.initAll(apiKey, {
    engagement: {
      // Only needed if your CSP uses a nonce for inline styles (step 6).
      nonce: options.nonce,
    },
  });

  markReady();
  return ready;
}

export { amplitude };
```

**Why.** A `'use client'` file still runs on the server during prerendering, where `window` doesn't exist, so the guard keeps `next build` working. With the Unified SDK, the guides and surveys SDK initializes and boots itself with the Analytics user and session. Don't call `engagement.init()` or `engagement.boot()` yourself: calling `boot` more than once causes unexpected behavior, especially for guides and surveys set to appear immediately.

Session replay is bundled in the same package but records only when `sessionReplay.sampleRate` is above zero, and this config doesn't set one. A sample rate set in Session Replay Settings in Amplitude overrides the code, so if a project admin turned it on there, sessions record. Set it to `0` there to keep recording off.

**Plugin variant.** If your `lib/amplitude.ts` initializes `@amplitude/analytics-browser` instead of Unified, install `@amplitude/engagement-browser` and add the plugin before `init`:

lib/amplitude.ts (plugin variant, excerpt of the init body)

```typescript
import * as amplitude from '@amplitude/analytics-browser';
import { plugin as engagementPlugin } from '@amplitude/engagement-browser';

amplitude.add(engagementPlugin());
await amplitude.init(apiKey).promise;
```

Order matters: adding the plugin after `init` has started can boot guides and surveys with a missing or wrong user. Keep the rest of the file (`started`, `ready`, `amplitudeReady`) as above.

## 3. Mount it and hand over the Next.js router

Initialize from a client component mounted in the root layout, which stays mounted across App Router navigations. Once the SDK is ready, give it a router function so guide links navigate client-side.

components/AnalyticsProvider.tsx

```typescript
'use client';

import { useEffect, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { initAmplitude } from '@/lib/amplitude';

export function AnalyticsProvider({
  children,
  nonce,
}: {
  children: ReactNode;
  nonce?: string;
}) {
  const router = useRouter();

  useEffect(() => {
    let cancelled = false;

    initAmplitude({ nonce }).then(() => {
      if (cancelled) return;
      window.engagement?.setRouter((url) => {
        const target = new URL(url, window.location.origin);
        if (target.origin === window.location.origin) {
          router.push(target.pathname + target.search + target.hash);
        } else {
          window.location.assign(url);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [router, nonce]);

  return <>{children}</>;
}
```

app/layout.tsx

```typescript
import type { ReactNode } from 'react';
import { AnalyticsProvider } from '@/components/AnalyticsProvider';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AnalyticsProvider>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}
```

`window.engagement` isn't in TypeScript's `Window` type. Declare the parts you call:

types/engagement.d.ts

```typescript
export {};

declare global {
  interface Window {
    engagement?: {
      setRouter: (routerFn: (url: string) => void) => void;
      gs: {
        show: (key: string, stepIndex?: number) => void;
        closeAll: () => void;
      };
      _debugStatus?: () => unknown;
    };
  }
```

**Why.** `setRouter` only affects link actions whose URL behavior is set to **Use router** in the guide or survey builder. **Same tab** and **New tab** ignore it. Change that setting on every link action once this code ships. The same-origin check keeps external links as normal navigations, since `router.push` is for routes inside your app.

## 4. Identify the user before you target

Targeting by user property or cohort, and the default User ID bucketing for rollouts, need to know who the user is. Set the user ID as soon as auth resolves, and set the properties you plan to target on.

components/IdentifyUser.tsx

```typescript
'use client';

import { useEffect } from 'react';
import { amplitude, amplitudeReady } from '@/lib/amplitude';

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

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    let cancelled = false;

    amplitudeReady().then(() => {
      if (cancelled) return;

      // Signed out or not signed in yet: keep the same device ID.
      // Call amplitude.reset() only from your sign-out handler.
      if (!user) return;

      // Amplitude drops IDs shorter than 5 characters. Use the same
      // string your server sends as user_id.
      amplitude.setUserId(`user-${user.id}`);

      const identify = new amplitude.Identify();
      identify.set('plan', user.plan); // flat string: targetable
      amplitude.identify(identify);
    });

    return () => {
      cancelled = true;
    };
  }, [user?.id, user?.plan]);

  return null;
}
```

Call `amplitude.reset()` from your sign-out handler, not from `IdentifyUser`. A null user also describes every anonymous visitor on every page load, and `reset()` issues a new device ID each time it runs. That splits one visitor into many and breaks anything bucketed by device ID, such as feature flags and experiments.

Mount it once wherever your auth state lives, inside `AnalyticsProvider`. `IdentifyUser` waits on `amplitudeReady()` rather than calling `initAmplitude` itself, because React runs a child's effects before its parent's. A child that initialized first would start the SDK without the provider's options.

**Why.** Guides and surveys refresh targeting automatically when the user or user properties change, so the identify call is enough. Three targeting rules bite here. Set `plan` as a flat string: targeting doesn't flatten arrays of objects, so `subscription.plan` from an array isn't available. For "new users", use First Seen rather than a cohort, since cohort membership updates with a delay and won't match on a user's first visit. If you use a property as content (`@{{ property.plan }}`), it has to be set during the current client-side session, before the guide displays.

`setUserId`, `reset`, and `Identify` are called on the Unified SDK's namespace import, as in Amplitude's Next.js guide.

## 5. Open a survey from your own code

When a survey should open because someone clicked something, Amplitude recommends setting its trigger to **None** in the builder and calling `show` from the click handler. It's more precise than an event trigger and needs no extra tracking.

components/FeedbackButton.tsx

```typescript
'use client';

export function FeedbackButton() {
  return (
    <button
      type="button"
      onClick={() => window.engagement?.gs.show('product-feedback')}
    >
      Give feedback
    </button>
  );
}
```

Replace `product-feedback` with your survey's key from the builder. `show` ignores targeting rules and limits but still respects page targeting, so confirm the survey is allowed on the page where the button lives.

If you'd rather use the **On event tracked** trigger, the event has to fire in the browser, where the SDK can see it. An event tracked on the server, such as a `Plan Upgraded` event from a server action with `@amplitude/analytics-node`, never reaches the guides and surveys SDK. To show a survey after that action, call `show` from the client component once the action resolves.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server includes `list_guides_surveys` and `get_guide_or_survey`, which list your project's guides and surveys and return each one's page targeting and trigger configuration, so your agent can check that the key it wires into `show` exists and is allowed on that route. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 6. Allow Amplitude in your CSP

Skip this step if your app doesn't send a `Content-Security-Policy` header. If it does, add these sources to the directives you already have:

```text
# Add to your existing Content-Security-Policy
script-src https://*.amplitude.com;
connect-src https://*.amplitude.com;
img-src https://*.amplitude.com;
media-src https://*.amplitude.com;
style-src https://*.amplitude.com;
```

If your `style-src` blocks inline styles and uses a per-request nonce, pass that nonce to guides and surveys. With the common Next.js pattern where middleware sets an `x-nonce` request header, read it in the root layout and hand it to the provider, which passes it to `engagement.nonce` in step 2:

app/layout.tsx (nonce variant, Next.js 15)

```typescript
import type { ReactNode } from 'react';
import { headers } from 'next/headers';
import { AnalyticsProvider } from '@/components/AnalyticsProvider';

export default async function RootLayout({ children }: { children: ReactNode }) {
  const nonce = (await headers()).get('x-nonce') ?? undefined;

  return (
    <html lang="en">
      <body>
        <AnalyticsProvider nonce={nonce}>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}
```

On Next.js 13 and 14, `headers()` is synchronous: drop the `await` and the `async`. Reading headers makes every route dynamic, which a per-request nonce requires anyway.

## Verify it worked

Run `npm run dev`, sign in as a test user, publish a test guide targeted to that user (or use a share link from the builder), then check in this order:

1. **Browser console.** `window.engagement` returns an object, not `undefined`. Then run `window.engagement._debugStatus()`: `user.user_id` should be your `user-` prefixed ID, `stateInitialized` and `decideSuccessful` should be `true`, and `num_guides_surveys` should be non-zero on a page where a guide should show.
2. **Amplitude Chrome extension.** The guides and surveys tab shows four setup checks: Installed, Initialized, Analytics Connected, and Booted. Its troubleshooting section lists every published guide and survey for the API key and which trigger conditions pass. It doesn't update live, so let the page finish loading first.
3. **User lookup.** Go to Users > User Profiles and search your test user ID. The event stream fills in ten seconds to one minute and should show the guide and survey events the SDK sends, and the profile should show `plan`.

Click the guide's link action set to **Use router**: the page should change without a full reload, which you can confirm by the Network tab showing no new document request.

## Troubleshooting

### `window.engagement` is undefined

The SDK never initialized. Check that `AnalyticsProvider` is in `app/layout.tsx`, that `NEXT_PUBLIC_AMPLITUDE_API_KEY` is set (Next.js reads `.env.local` only at startup, so restart the dev server), and that nothing passes `engagement: { skip: true }`.

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

Something calls `engagement.boot()` or `engagement.init()` in addition to the Unified SDK, often inside a `useEffect` that Strict Mode runs twice. Delete those calls; unified boots guides and surveys for you.

### `_debugStatus()` shows no user, or the wrong one

The engagement plugin started before Analytics had a user. With unified, check that nothing else initializes Amplitude first. With the plugin variant, `amplitude.add(engagementPlugin())` has to run before `amplitude.init()`.

### Guide links reload the whole page

The link action's URL behavior isn't **Use router**, or `setRouter` never ran. Confirm `AnalyticsProvider` is a client component that calls `setRouter` after `initAmplitude()` resolves.

### A targeted guide never shows for a signed-in user

Run `_debugStatus()` and check `apiKey` belongs to the project where you published the guide, and that the user properties you target were set in this session. A cohort-based "new user" rule won't match on the first visit; use First Seen.

### An "On event tracked" survey never opens

The event is tracked on the server. Guides and surveys only sees events tracked in the browser. Call `window.engagement.gs.show()` from the client instead, as in step 5.

### Guides render unstyled or the console shows CSP errors

Add the directives from step 6. If inline styles are blocked, pass your nonce to `engagement.nonce`.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent. It uses the Unified SDK without a CSP nonce; the nonce layout and the plugin variant are in steps 6 and 2.

7 files, 153 lines

```typescript
// -- .env.local --
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key

// -- lib/amplitude.ts --
import * as amplitude from '@amplitude/unified';

let started = false;
let markReady: () => void = () => {};
const ready = new Promise<void>((resolve) => {
  markReady = resolve;
});

export function amplitudeReady() {
  return ready;
}

export async function initAmplitude(options: { nonce?: string } = {}) {
  if (typeof window === 'undefined' || started) return ready;

  const apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY;
  if (!apiKey) {
    console.warn('NEXT_PUBLIC_AMPLITUDE_API_KEY is not set; Amplitude is off.');
    return ready;
  }

  started = true;

  await amplitude.initAll(apiKey, {
    engagement: {
      nonce: options.nonce,
    },
  });

  markReady();
  return ready;
}

export { amplitude };

// -- types/engagement.d.ts --
export {};

declare global {
  interface Window {
    engagement?: {
      setRouter: (routerFn: (url: string) => void) => void;
      gs: {
        show: (key: string, stepIndex?: number) => void;
        closeAll: () => void;
      };
      _debugStatus?: () => unknown;
    };
  }

// -- components/AnalyticsProvider.tsx --
'use client';

import { useEffect, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { initAmplitude } from '@/lib/amplitude';

export function AnalyticsProvider({
  children,
  nonce,
}: {
  children: ReactNode;
  nonce?: string;
}) {
  const router = useRouter();

  useEffect(() => {
    let cancelled = false;

    initAmplitude({ nonce }).then(() => {
      if (cancelled) return;
      window.engagement?.setRouter((url) => {
        const target = new URL(url, window.location.origin);
        if (target.origin === window.location.origin) {
          router.push(target.pathname + target.search + target.hash);
        } else {
          window.location.assign(url);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [router, nonce]);

  return <>{children}</>;
}

// -- app/layout.tsx --
import type { ReactNode } from 'react';
import { AnalyticsProvider } from '@/components/AnalyticsProvider';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AnalyticsProvider>{children}</AnalyticsProvider>
      </body>
    </html>
  );
}

// -- components/IdentifyUser.tsx --
'use client';

import { useEffect } from 'react';
import { amplitude, amplitudeReady } from '@/lib/amplitude';

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

export function IdentifyUser({ user }: { user: User }) {
  useEffect(() => {
    let cancelled = false;

    amplitudeReady().then(() => {
      if (cancelled) return;
      if (!user) return;
      amplitude.setUserId(`user-${user.id}`);
      const identify = new amplitude.Identify();
      identify.set('plan', user.plan);
      amplitude.identify(identify);
    });

    return () => {
      cancelled = true;
    };
  }, [user?.id, user?.plan]);

  return null;
}

// -- components/FeedbackButton.tsx --
'use client';

export function FeedbackButton() {
  return (
    <button
      type="button"
      onClick={() => window.engagement?.gs.show('product-feedback')}
    >
      Give feedback
    </button>
  );
}
```

## Hand it to your agent

Prefer to have Cursor or Claude Code do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt

```text
Add Amplitude Guides and Surveys to this Next.js App Router app by
following https://amplitude.com/build/nextjs-guides-surveys exactly.
If the app already uses @amplitude/analytics-browser, add the
@amplitude/engagement-browser plugin with amplitude.add() before
amplitude.init(). Otherwise install @amplitude/unified and call initAll
once inside initAmplitude() in lib/amplitude.ts, behind a typeof window
check and a started flag, called from a 'use client' AnalyticsProvider in
app/layout.tsx. Never call engagement.init() or engagement.boot(). After
init resolves, call window.engagement.setRouter with next/navigation's
router.push for same-origin URLs. Set the user ID with a "user-" prefix
and flat user properties where auth resolves, and reset() on sign-out.
Open surveys from click handlers with window.engagement.gs.show(key).
Read NEXT_PUBLIC_AMPLITUDE_API_KEY from the environment, don't hardcode
it, and don't invent event names. Tell me Session Replay only records if a
sample rate is set in code or in Session Replay Settings. Report every
file you changed.
```

## What this unlocks

- [Guides and surveys](https://amplitude.com/guides-and-surveys) target `plan`, the same user property your charts break down by, so an upgrade guide reaches free users only.
- [Session replay](https://amplitude.com/session-replay), bundled in the same package, lets you watch what users did right after they answered `product-feedback`.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), also bundled, can gate the feature a guide announces, so the flag and the guide roll out to the same users.
- Analytics charts the guide and survey events the SDK sends, so you can see whether users who finished a guide went on to upgrade.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add product analytics to a Next.js appTrack Plan Upgraded from a server action with the same user ID.](/build/nextjs-product-analytics)[Web frameworksAdd session replay to a Next.js appRecord the sessions around a guide or survey.](/build/nextjs-session-replay)[Web frameworksAdd feature flags to a Next.js appGate the feature a guide announces behind a flag.](/build/nextjs-feature-flags)

Reference docs

[Next.js Installation Guide →](https://amplitude.com/docs/sdks/frameworks/nextjs-installation-guide)[Browser Unified SDK →](https://amplitude.com/docs/sdks/analytics/browser/browser-unified-sdk)[Guides and surveys web SDK →](https://amplitude.com/docs/sdks/guides-and-surveys/sdk)[Setup and Targeting →](https://amplitude.com/docs/guides-and-surveys/setup-and-target)[Debug with the Amplitude Chrome extension →](https://amplitude.com/docs/data/chrome-extension-debug)[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/nextjs-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)
