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 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 to get your project API key.

1. Install the SDK and turn on guides and surveys

Amplitude's Next.js guide recommends @amplitude/unified for new projects, and it includes guides and surveys by default.

Terminal
npm install @amplitude/unified
.env.local
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
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)
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
'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>
  );
}

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

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;
    };
  }
}

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

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:

# 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)
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().

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
// -- .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
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 target plan, the same user property your charts break down by, so an upgrade guide reaches free users only.
  • Session replay, bundled in the same package, lets you watch what users did right after they answered product-feedback.
  • Feature 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

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.