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@latestis fine. - Some way to know who is signed in. This page calls it
useAuth()on the client andgetCurrentUser()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.
npm install @amplitude/unified @amplitude/analytics-nodeAdd 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.
NEXT_PUBLIC_AMPLITUDE_API_KEY=your-project-api-key
AMPLITUDE_API_KEY=your-project-api-key2. 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.
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.
'use client';
import type { ReactNode } from 'react';
import '@/lib/amplitude';
export function AnalyticsProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}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.
'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.
'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.
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;
}'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:
- Browser console. To watch each call, add
logLevel: amplitude.Types.LogLevel.Debugto theanalyticsoptions inlib/amplitude.tswhile you test. You should seeProject Createdtracked and uploaded. The SDK compresses request bodies to Amplitude's endpoints, so the console is easier to read than the Network tab. - Network tab. Filter for
api2.amplitude.com. Each upload is aPOSTto/2/httpapithat returns200. - 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.
- 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 Createdwithtemplate, andPlan Upgradedwithplan, all on one user, and the profile showsplan.
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.
// -- .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.
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 Createdfired with eachtemplate. - 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 Upgradedas the metric. - Guides and surveys, also bundled, can reach users whose
planis still free.
One integration; everything else reads from the same events.
Next steps
Reference docs
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.