---
title: "How to add product analytics to a Next.js app"
description: "Install Amplitude in the Next.js App Router, identify signed-in users, track a custom event from the browser and a conversion from a server action."
canonical: "https://amplitude.com/en-us/build/nextjs-product-analytics"
---

# How to add product analytics to a Next.js app

Install Amplitude in the Next.js App Router, identify signed-in users, track a custom event from the browser and a conversion from a server action.

Source: https://amplitude.com/en-us/build/nextjs-product-analytics

---

# 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](https://amplitude.com/amplitude-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](https://app.amplitude.com/signup) to get your project API key.

## 1. Install the SDKs

The [Next.js installation guide](https://amplitude.com/docs/sdks/frameworks/nextjs-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

```sh
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

```sh
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

```typescript
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.&#x20;**&#x49;nitializing 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

```typescript
'use client';

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

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

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

```typescript
'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.&#x20;**&#x41;n 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

```typescript
'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.&#x20;**&#x57;orking 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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 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

```typescript
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

```typescript
'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.&#x20;**&#x54;he 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

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

```text
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](https://amplitude.com/session-replay), once you set a sample rate, lets you watch the sessions where `Project Created` fired with each `template`.
- [Heatmaps](https://amplitude.com/heatmaps) show where users click before they create a project, built from those replays.
- [Feature Experiment](https://amplitude.com/amplitude-experiment), bundled in the same package, can test a new pricing page with `Plan Upgraded` as the metric.
- [Guides and surveys](https://amplitude.com/guides-and-surveys), also bundled, can reach users whose `plan` is still free.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add web analytics to a Next.js appTraffic, campaigns, and channels from the same SDK.](/build/nextjs-web-analytics)[Use caseTrack events from React Server ComponentsWhere server-side tracking belongs in the App Router, and where it doesn't.](/build/nextjs-server-components-tracking)[Web frameworksHow to add feature flags and experiments to a Next.js appPut a flag on the pricing page and measure Plan Upgraded.](/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)[Browser SDK 2 →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2)[Node.js SDK →](https://amplitude.com/docs/sdks/analytics/node/node-js-sdk)[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/nextjs-product-analytics.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)
