---
title: "Track events from React Server Components"
description: "Track Amplitude events from Next.js server actions and route handlers instead of RSC render, with the right IDs, flushing, and no duplicates."
canonical: "https://amplitude.com/en-us/build/nextjs-server-components-tracking"
---

# Track events from React Server Components

Track Amplitude events from Next.js server actions and route handlers instead of RSC render, with the right IDs, flushing, and no duplicates.

Source: https://amplitude.com/en-us/build/nextjs-server-components-tracking

---

# Track events from React Server Components

Move Amplitude tracking out of Server Component render and into server actions and route handlers, pass the browser's device ID and your user ID to the Node.js SDK, and flush before each request ends. About 15 minutes, all copy-paste.

- Next.js 14+
- App Router
- Node SDK 1.x
- 15 min

Chris Tam · Last updated 25 September 2026

Next.js is one of the most popular frameworks for building web apps. In the App Router, most components are React Server Components, which run on the server where the browser SDK can't.

In this tutorial, you'll [track events](https://amplitude.com/amplitude-analytics) from server actions and route handlers instead of render, so every event counts a real user action.

## What you'll build

A pricing page whose Server Component tracks nothing, a server action that tracks `Plan Upgraded` with `plan` and `source` properties, and a route handler that tracks `Report Exported` with a `format` property. Both server events carry your signed-in user's ID and the browser SDK's device ID, and both flush before the request ends. By the end you'll see those server events in Amplitude on the same user as their browser events.

## Prerequisites

- A Next.js 14 or later project using the App Router. Server actions are stable from Next.js 14.
- The browser SDK already initialized in a client component, as on [Add analytics to your Next.js app](/build/nextjs-product-analytics).
- 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 Node.js SDK

[Amplitude's Next.js guide](https://amplitude.com/docs/sdks/frameworks/nextjs-installation-guide) says to use the Node.js SDK instead of the Browser SDK for server-side tracking. It's a separate package that sits next to the browser one.

Terminal

```sh
npm install @amplitude/analytics-node
```

Add a server-only key to the env file. The browser setup already uses `NEXT_PUBLIC_AMPLITUDE_API_KEY`; the server key has no `NEXT_PUBLIC_` prefix, so Next.js never puts it in the client bundle.

.env.local

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

Amplitude's Next.js guide names these separately, as a client key and a server key. Whatever values you use, both must belong to the same Amplitude project, or browser and server events end up in different projects and never meet on one user.

## 2. Initialize once in a server module

Create one module that owns the Node.js SDK. Server actions and route handlers import a single helper from it, and that helper tracks the event and then waits for the upload.

lib/amplitude-server.ts

```typescript
import * as amplitude from '@amplitude/analytics-node';

let started = false;

function ensureStarted() {
  if (started) return;

  const apiKey = process.env.AMPLITUDE_API_KEY;
  if (!apiKey) {
    throw new Error('AMPLITUDE_API_KEY is not set');
  }

  amplitude.init(apiKey, {
    logLevel:
      process.env.NODE_ENV === 'production'
        ? amplitude.Types.LogLevel.Warn
        : amplitude.Types.LogLevel.Debug,
  });
  started = true;
}

export type AmplitudeIds = {
  user_id?: string;
  device_id?: string;
  insert_id?: string;
};

export async function trackServerEvent(
  eventType: string,
  eventProperties: Record<string, unknown>,
  ids: AmplitudeIds,
) {
  ensureStarted();

  if (!ids.user_id && !ids.device_id) {
    console.warn(`Amplitude: ${eventType} has no user_id or device_id, skipped`);
    return;
  }

  try {
    amplitude.track(eventType, eventProperties, ids);
    // A server action or route handler can finish, and a serverless
    // instance can freeze, before the background upload runs.
    await amplitude.flush().promise;
  } catch (error) {
    console.error(`Amplitude: failed to track ${eventType}`, error);
  }
```

**Why.** The SDK queues events in memory and uploads every 10 seconds by default. On a long-lived Node server that's fine, but a serverless function can freeze as soon as the response goes out, and the queue goes with it. `await amplitude.flush().promise` makes the request wait for the upload. The `started` flag keeps `init` to one call per server process. The early return exists because an event with neither ID can be rejected with a 400. For batching, EU residency, and the other options, see the [Node.js SDK reference](https://amplitude.com/docs/sdks/analytics/node/node-js-sdk).

## 3. Keep tracking out of render

A Server Component's body is a render function, not an event handler. Leave it free of `track` calls and let the browser SDK record the page view, since autocapture tracks page views by default and only fires in a real browser tab.

app/pricing/page.tsx

```typescript
import { UpgradeButton } from './upgrade-button';

// No Amplitude calls in this file. Next.js renders it on visits,
// <Link> prefetches, static builds, and revalidation, so a track call
// here would count all of those as "views". The browser SDK's page
// view autocapture records the visit instead.
export default function PricingPage() {
  return (
    <main>
      <h1>Pricing</h1>
      <section>
        <h2>Pro</h2>
        <p>Everything in Free, plus unlimited projects.</p>
        <UpgradeButton plan="pro" />
      </section>
    </main>
  );
}
```

The rule that follows: server-side tracking goes where a user action lands on the server, which in the App Router means a server action or a route handler. Those run once per form submit, button click, or API call. Render doesn't.

**Why.** Prefetching and caching are how Next.js keeps navigation fast, so they are on in production by default. Disabling them to make render-time tracking accurate trades page speed for a metric you can get from the browser for free.

## 4. Track from a server action

The server knows who is signed in, but it doesn't know the browser SDK's device ID. The browser does, through `getDeviceId()`, so the client component passes it as an argument to the action. It also passes an `insert_id` generated once per mount, so a double-click that fires the action twice produces one event.

app/pricing/upgrade-button.tsx

```typescript
'use client';

import { useRef, useTransition } from 'react';
import { amplitude } from '@/lib/amplitude';
import { upgradePlan } from './actions';

export function UpgradeButton({ plan }: { plan: string }) {
  const [pending, startTransition] = useTransition();
  // One ID per upgrade attempt. Amplitude drops a second event with the
  // same insert_id and device_id within 7 days.
  const insertId = useRef(crypto.randomUUID());

  return (
    <button
      disabled={pending}
      onClick={() =>
        startTransition(async () => {
          await upgradePlan({
            plan,
            deviceId: amplitude.getDeviceId() ?? null,
            insertId: insertId.current,
          });
        })
      }
    >
      {pending ? 'Upgrading...' : `Upgrade to ${plan}`}
    </button>
  );
}
```

The server action reads the user from your session, runs your billing logic, and tracks the event with both IDs.

app/pricing/actions.ts

```typescript
'use server';

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

export async function upgradePlan(input: {
  plan: string;
  deviceId: string | null;
  insertId: string;
}) {
  const user = await getCurrentUser();

  // ... your billing logic

  await trackServerEvent(
    'Plan Upgraded',
    { plan: input.plan, source: 'pricing_page' },
    {
      // Prefix integer IDs: Amplitude drops IDs shorter than 5 characters.
      user_id: user ? `user-${user.id}` : undefined,
      device_id: input.deviceId ?? undefined,
      insert_id: input.insertId,
    },
  );

  return { ok: true };
}
```

`getCurrentUser` stands in for whatever your auth library provides. This stub keeps the example compiling until you swap it.

lib/session.ts

```typescript
// Replace with your auth library's server-side session lookup.
export async function getCurrentUser(): Promise<{ id: string } | null> {
  return null;
}
```

**Why.** The `user-` prefix only works if your browser code passes the same string to `setUserId`. If the two sides disagree, one person becomes two users, which is the most common reason people can't find their data. The device ID matters for anonymous visitors: without it, the server event carries no browser device ID, so it won't stitch to the anonymous browsing that came before. Treat `deviceId` as untrusted input, since anyone can call a server action with any arguments.

The browser SDK also stores the device ID in an `AMP_` cookie, which the server could read with `cookies()`. The docs don't document that cookie's encoding, so this page passes the value explicitly instead of parsing it.

**MCP.** Working in Cursor or Claude Code? 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 `planUpgraded` next to `Plan Upgraded`. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 5. Track from a route handler

Route handlers are the other place a user action reaches the server, for example a `fetch` from a client component. Send the device ID in a header, the same `x-amplitude-device-id` convention the [Node.js page](/build/nodejs-product-analytics) uses, so one pattern covers both.

app/api/reports/export/route.ts

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { trackServerEvent } from '@/lib/amplitude-server';
import { getCurrentUser } from '@/lib/session';

// The Node.js SDK is documented for Node.js. Pin this route to it.
export const runtime = 'nodejs';

export async function POST(request: NextRequest) {
  const user = await getCurrentUser();
  const { format } = await request.json();

  // ... build the export

  await trackServerEvent(
    'Report Exported',
    { format },
    {
      user_id: user ? `user-${user.id}` : undefined,
      device_id: request.headers.get('x-amplitude-device-id') ?? undefined,
    },
  );

  return NextResponse.json({ ok: true });
}
```

The client calls it with the browser SDK's device ID attached:

app/reports/export-button.tsx

```typescript
'use client';

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

export function ExportButton() {
  async function handleExport() {
    await fetch('/api/reports/export', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-amplitude-device-id': amplitude.getDeviceId() ?? '',
      },
      body: JSON.stringify({ format: 'csv' }),
    });
  }

  return <button onClick={handleExport}>Export CSV</button>;
}
```

**Why.** Use `POST` for anything you track. Before Next.js 15, a `GET` route handler that doesn't read the request could be cached and run at build time, which is the render problem again in a different file. `POST` handlers always run per request.

## 6. Handle the edge runtime

Amplitude's docs describe `@amplitude/analytics-node` for Node.js and don't say whether it runs on the Next.js edge runtime. Until they do, keep tracked routes on `runtime = 'nodejs'` as in step 5.

If a route has to run on the edge, skip the SDK and call the HTTP V2 API with `fetch`, which the edge runtime supports. The SDK uses the same endpoint under the hood.

lib/amplitude-edge.ts

```typescript
// Use api.eu.amplitude.com instead if your project is in the EU data center.
const ENDPOINT = 'https://api2.amplitude.com/2/httpapi';

export async function trackEdgeEvent(event: {
  event_type: string;
  user_id?: string;
  device_id?: string;
  insert_id?: string;
  event_properties?: Record<string, unknown>;
}) {
  if (!event.user_id && !event.device_id) return;

  const response = await fetch(ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      api_key: process.env.AMPLITUDE_API_KEY,
      events: [{ ...event, time: Date.now() }],
    }),
  });

  if (!response.ok) {
    console.error('Amplitude HTTP V2 error', response.status, await response.text());
  }
```

Await it in the handler the same way you await `trackServerEvent`. There's no queue here, so each call is one request, which is fine for a handful of events per request and wasteful for dozens.

**Why.** `time` goes in milliseconds since epoch; any other format returns a 400. The `insert_id` field is where the HTTP V2 API does its 7-day deduplication, so pass one through when the client sends it.

## Verify it worked

Run `npm run dev`, sign in, click the upgrade button and the export button, then check in this order:

1. **Server terminal.** With `logLevel` at Debug outside production, the terminal running `next dev` prints the `track` call and the upload for each click. Nothing prints in the browser console for these events, because they never run there.
2. **Network tab.** The click shows a request to your page (the server action) or to `/api/reports/export`. You won't see a request to Amplitude from the browser for `Plan Upgraded`; that one leaves from the server.
3. **Ingestion Debugger.** In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show the uploads. It shows counts, not individual events.
4. **User lookup.** Go to Users > User Profiles and search for your `user-` prefixed ID or the device ID from `amplitude.getDeviceId()` in the browser console. `Plan Upgraded` with `plan: pro` and `Report Exported` with `format: csv` appear in the same event stream as your browser page views, within ten seconds to a minute.

Then check for render-time duplicates. Run `npm run build && npm start`, since `<Link>` only prefetches in production, and hover a link to `/pricing` without clicking. No new server events should appear in the terminal or the user's event stream.

## Troubleshooting

### `window is not defined` or a browser SDK error on the server

A Server Component or server file imports `lib/amplitude.ts` or `@amplitude/unified`. Keep browser SDK imports in `'use client'` files and use `lib/amplitude-server.ts` everywhere else.

### Event counts far higher than real traffic

Something still tracks during render, so prefetches, builds, and revalidations count as events. Search your Server Components and layouts for `track` and move each call into a server action or route handler.

### Server events land on a different user than browser events

The device ID didn't reach the server, or the user ID string differs between sides. Log the `ids` object in `trackServerEvent`, and make sure the browser calls `setUserId` with the same `user-` prefixed value.

### Events show in the terminal but some never reach Amplitude

The upload didn't finish before the function froze. Keep `await` on `trackServerEvent`, and don't call `amplitude.track` directly from an action without awaiting `flush().promise`.

### Build fails or the route errors on the edge runtime

The route has `runtime = 'edge'` and imports the Node.js SDK. Switch the route to `runtime = 'nodejs'`, or use `trackEdgeEvent` from step 6.

## Full code reference

Every file from this tutorial in one block. The browser SDK setup lives on [Add analytics to your Next.js app](/build/nextjs-product-analytics).

9 files, 196 lines

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

// -- lib/amplitude-server.ts --
import * as amplitude from '@amplitude/analytics-node';

let started = false;

function ensureStarted() {
  if (started) return;

  const apiKey = process.env.AMPLITUDE_API_KEY;
  if (!apiKey) {
    throw new Error('AMPLITUDE_API_KEY is not set');
  }

  amplitude.init(apiKey, {
    logLevel:
      process.env.NODE_ENV === 'production'
        ? amplitude.Types.LogLevel.Warn
        : amplitude.Types.LogLevel.Debug,
  });
  started = true;
}

export type AmplitudeIds = {
  user_id?: string;
  device_id?: string;
  insert_id?: string;
};

export async function trackServerEvent(
  eventType: string,
  eventProperties: Record<string, unknown>,
  ids: AmplitudeIds,
) {
  ensureStarted();

  if (!ids.user_id && !ids.device_id) {
    console.warn(`Amplitude: ${eventType} has no user_id or device_id, skipped`);
    return;
  }

  try {
    amplitude.track(eventType, eventProperties, ids);
    await amplitude.flush().promise;
  } catch (error) {
    console.error(`Amplitude: failed to track ${eventType}`, error);
  }

// -- lib/amplitude-edge.ts --
const ENDPOINT = 'https://api2.amplitude.com/2/httpapi';

export async function trackEdgeEvent(event: {
  event_type: string;
  user_id?: string;
  device_id?: string;
  insert_id?: string;
  event_properties?: Record<string, unknown>;
}) {
  if (!event.user_id && !event.device_id) return;

  const response = await fetch(ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      api_key: process.env.AMPLITUDE_API_KEY,
      events: [{ ...event, time: Date.now() }],
    }),
  });

  if (!response.ok) {
    console.error('Amplitude HTTP V2 error', response.status, await response.text());
  }

// -- lib/session.ts --
export async function getCurrentUser(): Promise<{ id: string } | null> {
  return null;
}

// -- app/pricing/page.tsx --
import { UpgradeButton } from './upgrade-button';

export default function PricingPage() {
  return (
    <main>
      <h1>Pricing</h1>
      <section>
        <h2>Pro</h2>
        <p>Everything in Free, plus unlimited projects.</p>
        <UpgradeButton plan="pro" />
      </section>
    </main>
  );
}

// -- app/pricing/upgrade-button.tsx --
'use client';

import { useRef, useTransition } from 'react';
import { amplitude } from '@/lib/amplitude';
import { upgradePlan } from './actions';

export function UpgradeButton({ plan }: { plan: string }) {
  const [pending, startTransition] = useTransition();
  const insertId = useRef(crypto.randomUUID());

  return (
    <button
      disabled={pending}
      onClick={() =>
        startTransition(async () => {
          await upgradePlan({
            plan,
            deviceId: amplitude.getDeviceId() ?? null,
            insertId: insertId.current,
          });
        })
      }
    >
      {pending ? 'Upgrading...' : `Upgrade to ${plan}`}
    </button>
  );
}

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

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

export async function upgradePlan(input: {
  plan: string;
  deviceId: string | null;
  insertId: string;
}) {
  const user = await getCurrentUser();

  await trackServerEvent(
    'Plan Upgraded',
    { plan: input.plan, source: 'pricing_page' },
    {
      user_id: user ? `user-${user.id}` : undefined,
      device_id: input.deviceId ?? undefined,
      insert_id: input.insertId,
    },
  );

  return { ok: true };
}

// -- app/api/reports/export/route.ts --
import { NextRequest, NextResponse } from 'next/server';
import { trackServerEvent } from '@/lib/amplitude-server';
import { getCurrentUser } from '@/lib/session';

export const runtime = 'nodejs';

export async function POST(request: NextRequest) {
  const user = await getCurrentUser();
  const { format } = await request.json();

  await trackServerEvent(
    'Report Exported',
    { format },
    {
      user_id: user ? `user-${user.id}` : undefined,
      device_id: request.headers.get('x-amplitude-device-id') ?? undefined,
    },
  );

  return NextResponse.json({ ok: true });
}

// -- app/reports/export-button.tsx --
'use client';

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

export function ExportButton() {
  async function handleExport() {
    await fetch('/api/reports/export', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-amplitude-device-id': amplitude.getDeviceId() ?? '',
      },
      body: JSON.stringify({ format: 'csv' }),
    });
  }

  return <button onClick={handleExport}>Export CSV</button>;
}
```

## Hand it to your agent

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

Using Cursor or Claude Code? Paste this prompt

```text
Move Amplitude server-side tracking in this Next.js App Router project out
of React Server Component render, following
https://amplitude.com/build/nextjs-server-components-tracking exactly.
Install @amplitude/analytics-node and create lib/amplitude-server.ts with a
trackServerEvent helper that calls init once and awaits
amplitude.flush().promise after each track call, reading AMPLITUDE_API_KEY
(no NEXT_PUBLIC_ prefix) from the environment. Remove every track call from
Server Components, layouts, and GET route handlers; track only in server
actions and POST route handlers. Pass user_id as "user-" plus the session
user's ID, and device_id from amplitude.getDeviceId() in the browser (as a
server action argument or an x-amplitude-device-id header). Pin tracked
routes to runtime = 'nodejs'. Don't hardcode the key. Don't track page views
on the server; the browser SDK already does. Don't invent event names; check
the existing taxonomy with the Amplitude MCP server if it's connected.
Report every file you changed and every track call you moved or removed.
```

## What this unlocks

- [Session replay](https://amplitude.com/session-replay) shows the browser sessions of users who fired `Plan Upgraded`, because server and browser events share the same user.
- [Heatmaps](https://amplitude.com/heatmaps) on `/pricing` show where people clicked before the upgrade fired.
- [Feature Experiment](https://amplitude.com/amplitude-experiment) can use `Plan Upgraded` as the primary metric for a pricing test, counted once per upgrade rather than once per prefetch.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can target users whose `plan` just changed to `pro`.

One integration; everything else reads from the same events.

## Next steps

[Web frameworksHow to add product analytics to a Next.js appThe browser half of this setup: init, page views, and identity in the App Router.](/build/nextjs-product-analytics)[Use caseUse feature flags with server-side renderingEvaluate flags on the server without the same render-time traps.](/build/nextjs-feature-flags-ssr)[Web frameworksAdd analytics to your Node.js backendThe same device ID header pattern for an Express server.](/build/nodejs-product-analytics)

Reference docs

[Next.js Installation Guide: server-side setup →](https://amplitude.com/docs/sdks/frameworks/nextjs-installation-guide)[Node.js SDK →](https://amplitude.com/docs/sdks/analytics/node/node-js-sdk)[HTTP V2 API →](https://amplitude.com/docs/apis/analytics/http-v2)[Browser SDK 2: identity state →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2#read-and-update-identity-state)[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-server-components-tracking.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)
