How to add product analytics to a Node.js backend

Install the Amplitude Node.js SDK in an Express backend, attach the right user or device ID to every server event, and flush the queue before the process exits. About 15 minutes, all copy-paste.

  • Node 20.6+
  • Express
  • Node SDK 1.x
  • 15 min

Chris Tam · Last updated 24 September 2026

Node.js runs a huge share of web backends, and your backend is where events like sign-ups and payments should be tracked, because ad blockers and closed tabs can't drop them.

In this tutorial, you'll add Amplitude to an Express app, attach the right user to every event, and make sure nothing is lost when the process shuts down.

What you'll build

An Express backend that tracks a Plan Upgraded event with plan and source properties, sets plan as a user property when someone signs in, stitches anonymous requests to the browser's device ID, and drains its event queue on shutdown. By the end you'll see live server events in Amplitude's event stream.

Prerequisites

  • A Node.js project using Express. This page uses node --env-file, which needs Node 20.6 or later.
  • "type": "module" in package.json, so the import syntax below works as written.
  • 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 SDK

The Node.js SDK is its own package, separate from the browser SDKs.

Terminal
npm install @amplitude/analytics-node

Put the key in an env file rather than in code. A project API key is public and its scope is limited to the minimum needed to ingest data, so it is not a secret in the security sense, but a hardcoded key in a public repo is permanent.

.env
AMPLITUDE_API_KEY=your-project-api-key

2. Initialize once per process

Create one module that owns the SDK. Every other file imports from it, so init runs exactly once no matter how many routes use analytics.

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

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

export { amplitude };

Why. ES modules are evaluated once and cached, so importing this file from ten routes still calls init one time. If you need a second Amplitude project, use createInstance() instead of calling init again. For batching, EU residency, and the rest of the options, see the Node.js SDK reference.

3. Resolve identity in middleware

Resolve who the request belongs to once, in middleware, and hang it on req. Routes then pass req.amplitudeIds to every call instead of each one working it out.

middleware/amplitude-identity.js
export function amplitudeIdentity(req, res, next) {
  const ids = {};

  // Signed-in user. Prefix integer primary keys: Amplitude drops IDs
  // shorter than 5 characters, and "42" is two.
  if (req.user?.id != null) {
    ids.user_id = `user-${req.user.id}`;
  }

  // Anonymous visitor. The browser sends its SDK device ID in a header
  // so server events join the same anonymous user.
  const deviceId = req.get('x-amplitude-device-id');
  if (deviceId) {
    ids.device_id = deviceId;
  }

  req.amplitudeIds = ids;
  next();
}

On the browser side, send the device ID with your API requests. The Browser SDK exposes it through getDeviceId():

src/api.js (browser)
import * as amplitude from '@amplitude/analytics-browser';

export function apiFetch(path, options = {}) {
  return fetch(path, {
    ...options,
    headers: {
      ...options.headers,
      'x-amplitude-device-id': amplitude.getDeviceId() ?? '',
    },
  });
}

Why. An event with neither ID can be rejected with a 400, and an ID under 5 characters gets stripped, so a raw integer user ID silently turns into "no ID". The user- prefix only works if your browser code uses the same string in setUserId. If the two sides disagree, one person shows up as two users, which is the most common reason people can't find their data.

4. Set user properties on sign-in

Send an Identify call when a user signs in, so their plan shows up as a user property on every event that follows.

routes/auth.js
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';

export const auth = Router();

auth.post('/login', async (req, res) => {
  // ... your authentication logic sets req.user
  const user = req.user;

  const identify = new amplitude.Identify();
  identify.set('plan', user.plan);
  identify.setOnce('signup_source', user.signupSource ?? 'unknown');

  amplitude.identify(identify, { user_id: `user-${user.id}` });

  res.json({ ok: true });
});

The profile updates right away, but the new plan value only appears in chart results from the next event that user sends. Identify affects events going forward, not the ones already recorded.

5. Track a conversion from a route

Billing events belong on the server: ad blockers can't drop them and closing a tab can't cancel them. Pass the IDs the middleware resolved.

routes/billing.js
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';

export const billing = Router();

billing.post('/upgrade', async (req, res) => {
  const { plan } = req.body;

  // ... your billing logic

  amplitude.track(
    'Plan Upgraded',
    { plan, source: 'billing_page' },
    req.amplitudeIds,
  );

  res.json({ ok: true, plan });
});

The event joins the in-memory queue. The SDK uploads the queue every 10 seconds or once 300 events pile up, whichever comes first, so you don't await anything here on a long-running server.

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 planUpgraded next to Plan Upgraded. Set up the MCP server.

6. Flush before the process exits

This is the step most Node setups skip. Wire the app together, then drain the queue when the platform tells the process to stop.

server.js
import express from 'express';
import { amplitude } from './lib/amplitude.js';
import { amplitudeIdentity } from './middleware/amplitude-identity.js';
import { auth } from './routes/auth.js';
import { billing } from './routes/billing.js';

const app = express();
app.use(express.json());
// Mount your session or auth middleware here, before amplitudeIdentity,
// so req.user is populated.
app.use(amplitudeIdentity);
app.use('/auth', auth);
app.use('/billing', billing);

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, flushing Amplitude`);
  server.close();
  await amplitude.flush().promise;
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

Why. flush() returns a promise, and until it resolves the queued events exist only in this process's memory. Container platforms send SIGTERM on every deploy and scale-down, so without this handler you lose up to 10 seconds of events each time. In a serverless function, where the runtime can freeze right after the response, await amplitude.flush().promise at the end of the handler instead.

Add a start script that loads the env file:

package.json
{
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env server.js"
  }
}

Verify it worked

Run npm start, hit the upgrade route with a device ID header, then check in this order:

Terminal
curl -X POST http://localhost:3000/billing/upgrade \
  -H 'Content-Type: application/json' \
  -H 'x-amplitude-device-id: test-device-12345' \
  -d '{"plan":"pro"}'
  1. Process logs. With logLevel at Debug outside production, the terminal prints the track call and, within about 10 seconds, the upload. Press Ctrl+C and the shutdown line should print before the process exits.
  2. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show the upload. It shows counts, not individual events.
  3. User lookup. Go to Users > User Profiles and search for test-device-12345. Plan Upgraded with plan: pro appears in the event stream within ten seconds to a minute. For a signed-in test, search your user- prefixed ID and check that plan is set.

Troubleshooting

Nothing in the logs after a track call

The key is undefined, or the module threw at import. Confirm the start script passes --env-file=.env and that .env sits next to server.js.

Events show in logs but some never reach Amplitude

The process exited before the queue drained. Keep the SIGTERM handler from step 6, and in serverless handlers await flush().promise before returning.

400 response, or events arrive with no user

Neither ID was present, or the user ID was shorter than 5 characters and got removed. Log req.amplitudeIds and check that integer IDs carry the user- prefix.

One person appears as two users

The browser calls setUserId with a different string than the server sends. Use the same user-${id} format on both sides.

Events land in the wrong region

EU projects need serverZone: amplitude.Types.ServerZone.EU in the init options and an API key from the EU project.

Full code reference

Every server file from this tutorial in one block. The browser helper from step 3 lives in your frontend.

7 files, 97 lines
// -- .env --
AMPLITUDE_API_KEY=your-project-api-key

// -- package.json (excerpt) --
// "type": "module",
// "scripts": { "start": "node --env-file=.env server.js" }

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

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

export { amplitude };

// -- middleware/amplitude-identity.js --
export function amplitudeIdentity(req, res, next) {
  const ids = {};
  if (req.user?.id != null) {
    ids.user_id = `user-${req.user.id}`;
  }
  const deviceId = req.get('x-amplitude-device-id');
  if (deviceId) {
    ids.device_id = deviceId;
  }
  req.amplitudeIds = ids;
  next();
}

// -- routes/auth.js --
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';

export const auth = Router();

auth.post('/login', async (req, res) => {
  const user = req.user;
  const identify = new amplitude.Identify();
  identify.set('plan', user.plan);
  identify.setOnce('signup_source', user.signupSource ?? 'unknown');
  amplitude.identify(identify, { user_id: `user-${user.id}` });
  res.json({ ok: true });
});

// -- routes/billing.js --
import { Router } from 'express';
import { amplitude } from '../lib/amplitude.js';

export const billing = Router();

billing.post('/upgrade', async (req, res) => {
  const { plan } = req.body;
  amplitude.track(
    'Plan Upgraded',
    { plan, source: 'billing_page' },
    req.amplitudeIds,
  );
  res.json({ ok: true, plan });
});

// -- server.js --
import express from 'express';
import { amplitude } from './lib/amplitude.js';
import { amplitudeIdentity } from './middleware/amplitude-identity.js';
import { auth } from './routes/auth.js';
import { billing } from './routes/billing.js';

const app = express();
app.use(express.json());
app.use(amplitudeIdentity);
app.use('/auth', auth);
app.use('/billing', billing);

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

async function shutdown(signal) {
  console.log(`${signal} received, flushing Amplitude`);
  server.close();
  await amplitude.flush().promise;
  process.exit(0);
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

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 analytics to this Node.js Express backend by following
https://amplitude.com/build/nodejs-product-analytics exactly. Install
@amplitude/analytics-node and call init once in lib/amplitude.js, reading
AMPLITUDE_API_KEY from the environment. Add middleware that sets
req.amplitudeIds from req.user (prefix the ID with "user-") and the
x-amplitude-device-id header, and pass req.amplitudeIds to every track call.
Add SIGTERM and SIGINT handlers that await amplitude.flush().promise before
exiting. Don't hardcode the key. Don't invent event names; check the existing
taxonomy with the Amplitude MCP server if it's connected. Report every file
you changed and anything I need to add to .env.

What this unlocks

  • Session replay shows the browser session that led up to Plan Upgraded, once the device ID ties client and server together.
  • Heatmaps on your pricing page show where people clicked before the upgrade fired.
  • Feature Experiment can use Plan Upgraded as the primary metric for a pricing test.
  • Guides and surveys can target users whose plan property just changed.

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.