How to add product analytics to a Next.js app

Install the Amplitude Unified SDK in a Next.js App Router project, identify signed-in users, track one custom event from a click and one conversion from a server action, and confirm both land on the same user. About 15 minutes, all copy-paste.

  • Next.js 13+
  • App Router
  • Unified SDK 1.x
  • Node SDK 1.x
  • 15 min

Chris Tam · Last updated 29 September 2026

Next.js is one of the most popular frameworks for building web apps. Product analytics shows you what signed-in users actually do in it: which features they use, where they get stuck, and what leads to an upgrade.

In this tutorial, you'll add Amplitude to a Next.js App Router app, tie events to signed-in users, and track your first events from both the browser and the server.

What you'll build

A Next.js app that autocaptures page views and clicks, sets the signed-in user's ID and plan on every event, tracks Project Created with a template property from a button, and tracks Plan Upgraded from a server action on the same user ID. By the end you'll see both events in that user's event stream in Amplitude.

Prerequisites

  • A Next.js 13 or later project using the App Router, and Node.js 16.8 or later. npx create-next-app@latest is fine.
  • Some way to know who is signed in. This page calls it useAuth() on the client and getCurrentUser() on the server; swap in your auth library.
  • An Amplitude project API key. In Amplitude, go to Settings > Projects > your project > General.

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 SDKs

The Next.js installation guide recommends the Unified SDK for new projects, and the Node.js SDK for anything that runs on the server. You need both: the browser half captures what users do, and the server half records the events that must not be lost to an ad blocker or a closed tab.

Terminal
npm install @amplitude/unified @amplitude/analytics-node

Add your key twice. The NEXT_PUBLIC_ copy reaches the browser bundle; the unprefixed copy stays on the server. A 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 both in env files rather than in code.

.env.local
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
AMPLITUDE_API_KEY=your-project-api-key

2. Initialize once, on the client

Put all browser setup in one module. It initializes at import time, behind a check for window and a flag, so it runs once per page load.

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

let started = false;

function initAmplitude() {
  // Server rendering has no window; Fast Refresh re-runs modules.
  if (typeof window === 'undefined' || started) return;

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

  started = true;
  amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        elementInteractions: true, // clicks are off unless you name them
      },
    },
  });
}

initAmplitude();

export { amplitude };

Why. Initializing at module scope means the SDK exists before any component's effect runs, so the identify call in step 4 never races it. Passing an autocapture object keeps the defaults (page views, sessions, attribution, form interactions, file downloads) and adds clicks, which default to off.

The Unified SDK also includes session replay, but it records nothing until you set sessionReplay.sampleRate above 0, so this setup doesn't record sessions. It also starts guides and surveys by default; add engagement: { skip: true } to the options if you don't use them.

3. Load it from the root layout

A small client component imports the module and renders its children. Putting it in the root layout loads Amplitude on every route without making your pages client components.

components/AnalyticsProvider.tsx
'use client';

import type { ReactNode } from 'react';
import '@/lib/amplitude';

export function AnalyticsProvider({ children }: { children: ReactNode }) {
  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>
  );
}

Page views are now automatic. Autocapture sends [Amplitude] Page Viewed on the first load and on every App Router navigation, so don't add a usePathname hook that tracks them again.

4. Identify the signed-in user

Until you set a user ID, events belong to an anonymous device. Set it as soon as your session resolves, and reset on sign-out so the next person on the same browser isn't merged into the last one.

components/IdentifyUser.tsx
'use client';

import { useEffect } from 'react';
import { amplitude } from '@/lib/amplitude';
import { useAuth } from '@/lib/auth'; // your auth hook

export function IdentifyUser() {
  const { user } = useAuth();

  useEffect(() => {
    // Signed out or still loading: 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 the server sends as user_id in step 6.
    amplitude.setUserId(`user-${user.id}`);

    const identify = new amplitude.Identify();
    identify.set('plan', user.plan);
    amplitude.identify(identify);
  }, [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 and every signed-in user while the session is still loading, 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.

Render <IdentifyUser /> once, inside AnalyticsProvider in app/layout.tsx (the full code reference below shows where). The plan value updates the user's profile right away, but only shows up in charts from that user's next event.

Why. An integer ID like 42 is under the 5-character minimum, and Amplitude removes it from the event. The user- prefix fixes that, and it only works if the server uses the exact same string.

5. Track a custom event from a click

Autocapture covers page views, sessions, forms, and clicks. Track the actions that mean something to your product yourself, from the event handler, with the properties you'll want to break down by.

components/NewProjectButton.tsx
'use client';

import { amplitude } from '@/lib/amplitude';

export function NewProjectButton({ template }: { template: string }) {
  function handleClick() {
    // ... your create-project logic
    amplitude.track('Project Created', { template });
  }

  return <button onClick={handleClick}>Create project</button>;
}

Call track from the handler, not from an effect that watches state. A handler runs once per click; an effect reruns whenever its dependencies change, and twice in development under Strict Mode.

MCP. Working in Cursor, Claude Code, or Codex CLI? 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. Set up the MCP server.

6. Track a conversion from a server action

Billing events belong on the server, where an ad blocker or a closed tab can't drop them. The Next.js guide uses the Node.js SDK for this. Initialize it once in a server module and flush before the action returns.

lib/amplitude-server.ts
import { init, track, flush } from '@amplitude/analytics-node';

let started = false;

function ensureInit() {
  if (started) return;
  init(process.env.AMPLITUDE_API_KEY!);
  started = true;
}

export async function trackServerEvent(
  eventType: string,
  userId: string,
  eventProperties?: Record<string, unknown>,
) {
  ensureInit();
  track(eventType, eventProperties, { user_id: userId });
  // Serverless functions can freeze as soon as the action returns.
  await flush().promise;
}
app/actions/upgrade.ts
'use server';

import { trackServerEvent } from '@/lib/amplitude-server';
import { getCurrentUser } from '@/lib/auth'; // your server-side auth helper

export async function upgradePlan(plan: string) {
  const user = await getCurrentUser();

  // ... your billing logic

  await trackServerEvent('Plan Upgraded', `user-${user.id}`, {
    plan,
    source: 'billing_page',
  });
}

Why. The Node.js SDK queues events in memory and uploads them every 10 seconds. A server action on Vercel or any serverless host can freeze the moment it returns, so without await flush().promise the event never leaves. The user- prefix matches step 4, so the browser and server events land on one user instead of two.

Verify it worked

Run npm run dev, sign in, click through two routes, create a project, and run the upgrade action. Then check in this order:

  1. Browser console. To watch each call, add logLevel: amplitude.Types.LogLevel.Debug to the analytics options in lib/amplitude.ts while you test. You should see Project Created tracked and uploaded. The SDK compresses request bodies to Amplitude's endpoints, so the console is easier to read than the Network tab.
  2. Network tab. Filter for api2.amplitude.com. Each upload is a POST to /2/httpapi that returns 200.
  3. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads, from the browser and from the server.
  4. User lookup. Go to Users > User Profiles and search for your user- prefixed ID. Within ten seconds to a minute, the event stream shows [Amplitude] Page Viewed, Project Created with template, and Plan Upgraded with plan, all on one user, and the profile shows plan.

Troubleshooting

No requests in the Network tab

The key is undefined in the browser, so the warning from step 2 is in the console. The variable must start with NEXT_PUBLIC_, and Next.js only reads .env.local at startup, so restart the dev server.

"window is not defined" during build

Something imports the SDK from a Server Component or runs it during server rendering. Only import lib/amplitude.ts from files marked 'use client', and keep the typeof window check.

Project Created shows up on an anonymous user

The click happened before setUserId ran, or IdentifyUser isn't mounted. Render it once inside AnalyticsProvider, and check that user.id is in its dependency array.

Plan Upgraded never arrives

The action returned before the queue was sent. Keep await flush().promise in trackServerEvent, and check that AMPLITUDE_API_KEY (no prefix) is set in the environment the server runs in, including your production host.

One person appears as two users

The browser and server send different IDs. Both must send user-${user.id}, with the same prefix and the same underlying ID.

Events arrive but not in your project

EU projects need serverZone: 'EU' at the top level of the initAll options, and serverZone: Types.ServerZone.EU in the Node.js init options, with a key from the EU project.

Full code reference

Every file from this tutorial in one block, the version to hand to an agent.

8 files, 131 lines
// -- .env.local --
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
AMPLITUDE_API_KEY=your-project-api-key

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

let started = false;

function initAmplitude() {
  if (typeof window === 'undefined' || started) return;

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

  started = true;
  amplitude.initAll(apiKey, {
    analytics: {
      autocapture: {
        elementInteractions: true,
      },
    },
  });
}

initAmplitude();

export { amplitude };

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

import type { ReactNode } from 'react';
import '@/lib/amplitude';

export function AnalyticsProvider({ children }: { children: ReactNode }) {
  return <>{children}</>;
}

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

import { useEffect } from 'react';
import { amplitude } from '@/lib/amplitude';
import { useAuth } from '@/lib/auth';

export function IdentifyUser() {
  const { user } = useAuth();

  useEffect(() => {
    if (!user) return;
    amplitude.setUserId(`user-${user.id}`);
    const identify = new amplitude.Identify();
    identify.set('plan', user.plan);
    amplitude.identify(identify);
  }, [user?.id, user?.plan]);

  return null;
}

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

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

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

import { amplitude } from '@/lib/amplitude';

export function NewProjectButton({ template }: { template: string }) {
  function handleClick() {
    amplitude.track('Project Created', { template });
  }

  return <button onClick={handleClick}>Create project</button>;
}

// -- lib/amplitude-server.ts --
import { init, track, flush } from '@amplitude/analytics-node';

let started = false;

function ensureInit() {
  if (started) return;
  init(process.env.AMPLITUDE_API_KEY!);
  started = true;
}

export async function trackServerEvent(
  eventType: string,
  userId: string,
  eventProperties?: Record<string, unknown>,
) {
  ensureInit();
  track(eventType, eventProperties, { user_id: userId });
  await flush().promise;
}

// -- app/actions/upgrade.ts --
'use server';

import { trackServerEvent } from '@/lib/amplitude-server';
import { getCurrentUser } from '@/lib/auth';

export async function upgradePlan(plan: string) {
  const user = await getCurrentUser();
  await trackServerEvent('Plan Upgraded', `user-${user.id}`, {
    plan,
    source: 'billing_page',
  });
}

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 product analytics to this Next.js App Router project by
following https://amplitude.com/build/nextjs-product-analytics exactly.
Install @amplitude/unified and @amplitude/analytics-node. Initialize the
browser SDK once at module scope in lib/amplitude.ts behind a typeof window
check and a started flag, reading NEXT_PUBLIC_AMPLITUDE_API_KEY, with
autocapture elementInteractions on. Import it from a 'use client'
AnalyticsProvider in app/layout.tsx. Set the user ID as "user-" + id where
auth resolves, call reset() on sign-out, and send the same "user-" + id from
server actions through lib/amplitude-server.ts, awaiting flush().promise.
Do not initialize in useEffect, do not add a manual page view hook, don't
hardcode keys, and don't invent event names; check the existing taxonomy
with the Amplitude MCP server if it's connected. Report every file you
changed and what to add to .env.local.

What this unlocks

  • Session replay, once you set a sample rate, lets you watch the sessions where Project Created fired with each template.
  • Heatmaps show where users click before they create a project, built from those replays.
  • Feature Experiment, bundled in the same package, can test a new pricing page with Plan Upgraded as the metric.
  • Guides and surveys, also bundled, can reach users whose plan is still free.

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.