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 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.
  • 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 Node.js SDK

Amplitude's Next.js 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
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
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
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.

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

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 uses, so one pattern covers both.

app/api/reports/export/route.ts
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
'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
// 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.

9 files, 196 lines
// -- .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
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 shows the browser sessions of users who fired Plan Upgraded, because server and browser events share the same user.
  • Heatmaps on /pricing show where people clicked before the upgrade fired.
  • Feature 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 can target users whose plan just changed to pro.

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.