---
title: "How to add product analytics to a Node.js backend"
description: "Install the Amplitude Node.js SDK in an Express backend, attach user IDs to server events, flush on shutdown, and confirm events land."
canonical: "https://amplitude.com/en-us/build/nodejs-product-analytics"
---

# How to add product analytics to a Node.js backend

Install the Amplitude Node.js SDK in an Express backend, attach user IDs to server events, flush on shutdown, and confirm events land.

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

---

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

## 1. Install the SDK

[The Node.js SDK](https://amplitude.com/docs/sdks/analytics/node/node-js-sdk) is its own package, separate from the browser SDKs.

Terminal

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

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

```javascript
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](https://amplitude.com/docs/sdks/analytics/node/node-js-sdk).

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

```javascript
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)

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

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

```javascript
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](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

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

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

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

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

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

```text
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](https://amplitude.com/session-replay) shows the browser session that led up to `Plan Upgraded`, once the device ID ties client and server together.
- [Heatmaps](https://amplitude.com/heatmaps) on your pricing page 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.
- [Guides and surveys](https://amplitude.com/guides-and-surveys) can target users whose `plan` property just changed.

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, with page views and identity in the App Router.](/build/nextjs-product-analytics)[Use caseHow to send events to Amplitude from your serverPut server events in the user's session, dedupe webhook retries, and flush from jobs and serverless functions.](/build/send-events-server-side)[AI toolsAdd analytics to your app with Codex CLILet the agent read your taxonomy before it writes track calls.](/build/codex-cli-analytics)

Reference docs

[Debug Analytics →](https://amplitude.com/docs/analytics/debug-analytics)[Node.js SDK →](https://amplitude.com/docs/sdks/analytics/node/node-js-sdk)[Amplitude Quickstart →](https://amplitude.com/docs/get-started/amplitude-quickstart)[Browser SDK 2: identity state →](https://amplitude.com/docs/sdks/analytics/browser/browser-sdk-2#read-and-update-identity-state)[HTTP V2 API →](https://amplitude.com/docs/apis/analytics/http-v2)[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/nodejs-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)
