How to stitch client and server events to one user

Define the user ID once on the server, read the browser's device ID from a header or the Amplitude cookie, send both IDs on the sign-up event, hand the same user ID back to the browser, and reset cleanly on sign-out. About 20 minutes, all copy-paste.

  • Node 20.6+
  • Express
  • Unified SDK 1.x
  • Node SDK 1.x
  • 20 min

Chris Tam · Last updated 8 October 2026

A visitor browses your pricing page anonymously, signs up through your API, and pays through a webhook. Product analytics only tells that story if Amplitude sees one person, not three.

In this tutorial, you'll share one device ID between browser and server, link it to a user ID at sign-up, use one user ID format everywhere, and reset on sign-out. It builds on the Node.js product analytics page.

What you'll build

An Express backend and a browser app that agree on who the user is. Before sign-up, browser page views and server events share the browser's device ID. The server's Account Created event carries both that device ID and the new user ID, which joins the anonymous history to the account. The API returns the user ID it used, and the browser sets exactly that string. On sign-out, Signed Out goes out under the old identity and the browser gets a fresh device ID. By the end you'll see the pre-sign-up page views and Account Created on one user profile in Amplitude.

Prerequisites

  • The Express app from Add product analytics to a Node.js backend, with lib/amplitude.js, the amplitudeIdentity middleware, and routes/auth.js. This page replaces the middleware, routes/auth.js, and server.js.
  • A frontend that initializes @amplitude/unified with initAll using the same project API key as the server, as on the React product analytics page.
  • Node.js 20.6 or later and "type": "module" in package.json.

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. Define the user ID in one place

Every Amplitude call on the server, and the value the browser passes to setUserId, comes from this one function.

lib/identity.js
// The one place that turns an account into an Amplitude user ID.
// Never change this format once events have shipped.
export function amplitudeUserId(user) {
  // No account, no user ID. Never send placeholders like "null" or
  // "anonymous"; Amplitude would treat everyone as one person.
  if (user?.id == null) return undefined;

  // Prefix integer primary keys: Amplitude drops IDs shorter than
  // 5 characters, and "42" is two.
  return `user-${user.id}`;
}

Why. Amplitude can't merge two user IDs. If the browser sends user-42 and a worker sends 42, or one side sends an email address, that's two users forever, and fixing the code later only joins future activity. User IDs are case-sensitive and can't be changed after you set them, so base them on an immutable account ID rather than an email that can change. If you already send user IDs in some format, match it here instead of switching to the prefix.

Before sign-up, the device ID is the only thing tying browser and server events together. The Node.js product analytics page reads it from an x-amplitude-device-id header that your fetch calls set. Requests that can't set headers, such as a plain HTML form post or a server-rendered page load, still carry the browser SDK's cookie, so read that as a fallback.

The Experiment Node.js SDK ships an AmplitudeCookie helper that parses the cookie. Install it with a cookie parser for Express:

Terminal
npm install @amplitude/experiment-node-server cookie-parser

This file replaces middleware/amplitude-identity.js:

middleware/amplitude-identity.js
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
import { amplitudeUserId } from '../lib/identity.js';

// Browser SDK 2 cookies are named AMP_ plus the first 10 characters of
// the project API key. Pass true for the Browser SDK 2 format.
const ampCookieName = AmplitudeCookie.cookieName(process.env.AMPLITUDE_API_KEY, true);

function deviceIdFromCookie(req) {
  const raw = req.cookies?.[ampCookieName];
  if (!raw) return undefined;
  try {
    return AmplitudeCookie.parse(raw, true).device_id || undefined;
  } catch {
    return undefined;
  }
}

export function amplitudeIdentity(req, res, next) {
  const ids = {};

  const userId = amplitudeUserId(req.user);
  if (userId) {
    ids.user_id = userId;
  }

  // The header wins; the cookie covers form posts and page loads.
  const deviceId = req.get('x-amplitude-device-id') || deviceIdFromCookie(req);
  if (deviceId) {
    ids.device_id = deviceId;
  }

  // Session, IP, and user agent, as on the server-side events page.
  const sessionId = Number(req.get('x-amplitude-session-id'));
  if (Number.isSafeInteger(sessionId) && sessionId > 0) {
    ids.session_id = sessionId;
  }
  if (req.ip) {
    ids.ip = req.ip;
  }
  const userAgent = req.get('user-agent');
  if (userAgent) {
    ids.user_agent = userAgent;
  }

  req.amplitudeIds = ids;
  next();
}

Mount cookie-parser before the middleware. This server.js replaces the one from the product analytics page:

server.js
import express from 'express';
import cookieParser from 'cookie-parser';
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();
// One proxy hop (a load balancer) in front of the app, so req.ip is
// the client's address. Match this to your host.
app.set('trust proxy', 1);
app.use(express.json());
app.use(cookieParser());
// 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);

This middleware and the browser helper in step 4 also carry the session ID, IP address, and user agent from the server-side events page, so both pages end with the same files. If you followed that page, keep its checkout and webhooks route mounts in server.js.

Why. The cookie only reaches your server when the API shares a domain with the page that set it, so keep the header for API calls on another domain. The server reads the browser's device ID and never invents one: a server-made ID matches nothing in the browser and leaves those events on an orphan profile.

3. Send both IDs when someone signs up or signs in

This is the step that joins the history. The first event that carries both the browser's device ID and a user ID tells Amplitude that the anonymous activity on that device belongs to the account. This file replaces routes/auth.js:

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

export const auth = Router();

auth.post('/signup', async (req, res) => {
  // ... your sign-up logic creates the account and sets req.user
  const user = req.user;
  const userId = amplitudeUserId(user);

  // The middleware ran before the account existed, so req.amplitudeIds
  // has the device ID but no user ID yet. Add it here.
  const ids = { ...req.amplitudeIds, user_id: userId };

  amplitude.track('Account Created', { plan: user.plan }, ids);

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

auth.post('/login', async (req, res) => {
  // ... your authentication logic sets req.user
  const user = req.user;
  const userId = amplitudeUserId(user);
  const ids = { ...req.amplitudeIds, user_id: userId };

  const identify = new amplitude.Identify();
  identify.set('plan', user.plan);
  amplitude.identify(identify, ids);
  amplitude.track('Signed In', undefined, ids);

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

auth.get('/me', async (req, res) => {
  // Lets a page that loads already signed in pick up the same ID.
  res.json({ amplitudeUserId: amplitudeUserId(req.user) ?? null });
});

auth.post('/logout', async (req, res) => {
  // Still the signed-in user here, so this lands on their profile.
  amplitude.track('Signed Out', undefined, req.amplitudeIds);

  // ... your sign-out logic clears the session
  res.json({ ok: true });
});

Why. Amplitude treats anonymous events on a device as one person until it sees an event from that device with a user ID, then gives the anonymous events and the identified ones the same Amplitude ID. Sending device_id with the server event means that link exists even if the browser never fires another event after sign-up. On login, the same pair joins a new device, like a phone or a second browser, to an account that already exists. Sending user_id alone would still credit the event to the right user, but it wouldn't pull in what the person did on that device before signing in.

The response returns amplitudeUserId so the browser never builds the string itself. If two codebases each format the ID, they drift.

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 a new auth route sends the Signed In you already chart instead of inventing user_login next to it. Set up the MCP server.

4. Set the same user ID in the browser

The browser sends the device ID and session ID on every API call and sets whatever user ID the server returns. setUserId and reset are exported by @amplitude/unified; getDeviceId and getSessionId read the current identity state.

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

export function apiFetch(path, options = {}) {
  const headers = { 'Content-Type': 'application/json', ...options.headers };
  const deviceId = amplitude.getDeviceId();
  if (deviceId) headers['x-amplitude-device-id'] = deviceId;
  const sessionId = amplitude.getSessionId();
  if (sessionId) headers['x-amplitude-session-id'] = String(sessionId);
  return fetch(path, { ...options, headers });
}
src/auth.js (browser)
import * as amplitude from '@amplitude/unified';
import { apiFetch } from './api.js';

async function postJson(path, body) {
  const res = await apiFetch(path, { method: 'POST', body: JSON.stringify(body ?? {}) });
  if (!res.ok) throw new Error(`${path} failed with ${res.status}`);
  return res.json();
}

export async function signUp(form) {
  const { amplitudeUserId } = await postJson('/auth/signup', form);
  // Same string the server used. Don't rebuild it here.
  if (amplitudeUserId) amplitude.setUserId(amplitudeUserId);
}

export async function signIn(credentials) {
  const { amplitudeUserId } = await postJson('/auth/login', credentials);
  if (amplitudeUserId) amplitude.setUserId(amplitudeUserId);
}

// Call once on page load, after initAll.
export async function restoreIdentity() {
  const res = await apiFetch('/auth/me');
  if (!res.ok) return;
  const { amplitudeUserId } = await res.json();
  if (amplitudeUserId) amplitude.setUserId(amplitudeUserId);
}

export async function signOut() {
  await postJson('/auth/logout');
  // New device ID, no user ID: the next person here starts fresh.
  amplitude.reset();
}

On the React page, pass amplitudeUserId from your auth state into IdentifyUser and call setUserId with it, instead of building `user-${user.id}` in the component.

Why. Don't call reset() or set a new device ID during sign-up or sign-in. The login guidance is to keep one SDK instance and the same device ID across the anonymous and signed-in parts of a visit, then set the user ID. A reset at login hands the account a brand new device and strands the history you were trying to join.

5. Reset on sign-out, after the last event

signOut in step 4 already does this in the right order: the server tracks Signed Out under the user's IDs, and only after the response does the browser call reset().

Why. When someone signs out without a reset, Amplitude assumes anonymous events on that device still come from the last known user. On a shared laptop, the next person's browsing lands on the previous person's profile. reset() clears the user ID and issues a new device ID, so what follows starts a new anonymous user. Reset only on an explicit sign-out. An anonymous visitor isn't a signed-out user, and resetting on every page load gives one visitor a new device ID each time.

Verify it worked

Run npm start and your frontend. In a private browser window, visit two or three pages without signing in, then sign up a test account. Check in this order:

  1. Network tab. The POST /auth/signup request carries x-amplitude-device-id, and its response includes amplitudeUserId. Filter for api2.amplitude.com: the next browser upload after sign-up has that user_id and the same device_id as the uploads before it.
  2. Process logs. With logLevel at Debug outside production, the terminal prints the track call for Account Created with both user_id and device_id in its arguments, then the upload within about 10 seconds.
  3. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart shows the uploads from both the browser and the server. It shows counts, not individual events.
  4. User lookup. Go to Users > User Profiles and search the test account's user- ID. Within ten seconds to a minute, the event stream shows the [Amplitude] Page Viewed events from before sign-up, then Account Created, all on one profile.

Then sign out and visit one more page. In the Network tab, the next upload has a new device_id and no user_id. Search that new device ID in User Profiles: it's a separate anonymous user, not the test account.

To check the cookie fallback, submit any HTML form that posts to your server from the same domain, and log req.amplitudeIds in that route: device_id matches amplitude.getDeviceId() in the browser console.

Troubleshooting

Pre-sign-up page views aren't on the new user's profile

The device ID changed during sign-up, or the server event had no device_id. Check that nothing calls reset() or setDeviceId in the sign-up flow, and that req.amplitudeIds.device_id is set in the /signup route.

One person shows up as two users

Two sources send different user ID strings: user-42 and 42, a different case, or an email on one side. Route every user ID through amplitudeUserId and have the browser use the value the API returns. Amplitude can't merge the two existing profiles, so the fix only applies to new activity.

device_id is missing on form posts or page loads

The cookie name didn't match. The server's AMPLITUDE_API_KEY must be the same project key the browser uses, and cookieName and parse both need true for the Browser SDK 2 format. A request to an API on another domain doesn't carry the cookie at all; use the header there.

The next person on a shared device appears as the previous user

Sign-out doesn't call reset(). Call it from your sign-out handler after the logout request finishes.

Every signed-out user collapses into one profile

Something sends a placeholder such as "null", "undefined", or "anonymous" as the user ID. Amplitude rejects some of these outright and treats any other shared value as a single user. Send no user_id at all for anonymous traffic, which is what amplitudeUserId does.

Full code reference

Every file this page adds or changes, in one block. lib/amplitude.js and routes/billing.js stay as they are on the Node.js product analytics page. The two src/ files live in your frontend. 6 files.

6 files, 162 lines
// -- lib/identity.js --
export function amplitudeUserId(user) {
  if (user?.id == null) return undefined;
  return `user-${user.id}`;
}

// -- middleware/amplitude-identity.js --
import { AmplitudeCookie } from '@amplitude/experiment-node-server';
import { amplitudeUserId } from '../lib/identity.js';

const ampCookieName = AmplitudeCookie.cookieName(process.env.AMPLITUDE_API_KEY, true);

function deviceIdFromCookie(req) {
  const raw = req.cookies?.[ampCookieName];
  if (!raw) return undefined;
  try {
    return AmplitudeCookie.parse(raw, true).device_id || undefined;
  } catch {
    return undefined;
  }
}

export function amplitudeIdentity(req, res, next) {
  const ids = {};
  const userId = amplitudeUserId(req.user);
  if (userId) {
    ids.user_id = userId;
  }
  const deviceId = req.get('x-amplitude-device-id') || deviceIdFromCookie(req);
  if (deviceId) {
    ids.device_id = deviceId;
  }
  const sessionId = Number(req.get('x-amplitude-session-id'));
  if (Number.isSafeInteger(sessionId) && sessionId > 0) {
    ids.session_id = sessionId;
  }
  if (req.ip) {
    ids.ip = req.ip;
  }
  const userAgent = req.get('user-agent');
  if (userAgent) {
    ids.user_agent = userAgent;
  }
  req.amplitudeIds = ids;
  next();
}

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

export const auth = Router();

auth.post('/signup', async (req, res) => {
  // ... your sign-up logic creates the account and sets req.user
  const user = req.user;
  const userId = amplitudeUserId(user);
  const ids = { ...req.amplitudeIds, user_id: userId };
  amplitude.track('Account Created', { plan: user.plan }, ids);
  res.json({ ok: true, amplitudeUserId: userId });
});

auth.post('/login', async (req, res) => {
  // ... your authentication logic sets req.user
  const user = req.user;
  const userId = amplitudeUserId(user);
  const ids = { ...req.amplitudeIds, user_id: userId };
  const identify = new amplitude.Identify();
  identify.set('plan', user.plan);
  amplitude.identify(identify, ids);
  amplitude.track('Signed In', undefined, ids);
  res.json({ ok: true, amplitudeUserId: userId });
});

auth.get('/me', async (req, res) => {
  res.json({ amplitudeUserId: amplitudeUserId(req.user) ?? null });
});

auth.post('/logout', async (req, res) => {
  amplitude.track('Signed Out', undefined, req.amplitudeIds);
  // ... your sign-out logic clears the session
  res.json({ ok: true });
});

// -- server.js --
import express from 'express';
import cookieParser from 'cookie-parser';
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();
// One proxy hop (a load balancer) in front of the app, so req.ip is
// the client's address. Match this to your host.
app.set('trust proxy', 1);
app.use(express.json());
app.use(cookieParser());
// Mount your session or auth middleware here, before amplitudeIdentity.
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);

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

export function apiFetch(path, options = {}) {
  const headers = { 'Content-Type': 'application/json', ...options.headers };
  const deviceId = amplitude.getDeviceId();
  if (deviceId) headers['x-amplitude-device-id'] = deviceId;
  const sessionId = amplitude.getSessionId();
  if (sessionId) headers['x-amplitude-session-id'] = String(sessionId);
  return fetch(path, { ...options, headers });
}

// -- src/auth.js (browser) --
import * as amplitude from '@amplitude/unified';
import { apiFetch } from './api.js';

async function postJson(path, body) {
  const res = await apiFetch(path, { method: 'POST', body: JSON.stringify(body ?? {}) });
  if (!res.ok) throw new Error(`${path} failed with ${res.status}`);
  return res.json();
}

export async function signUp(form) {
  const { amplitudeUserId } = await postJson('/auth/signup', form);
  if (amplitudeUserId) amplitude.setUserId(amplitudeUserId);
}

export async function signIn(credentials) {
  const { amplitudeUserId } = await postJson('/auth/login', credentials);
  if (amplitudeUserId) amplitude.setUserId(amplitudeUserId);
}

export async function restoreIdentity() {
  const res = await apiFetch('/auth/me');
  if (!res.ok) return;
  const { amplitudeUserId } = await res.json();
  if (amplitudeUserId) amplitude.setUserId(amplitudeUserId);
}

export async function signOut() {
  await postJson('/auth/logout');
  amplitude.reset();
}

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
Make this app's browser and server Amplitude events resolve to one user by
following https://amplitude.com/build/stitch-client-server-events. Put the
user ID format in one server function (lib/identity.js) and use it for every
user_id; keep any format we already send instead of changing it. In the
Express amplitudeIdentity middleware, read device_id from the
x-amplitude-device-id header, falling back to the Amplitude cookie via
AmplitudeCookie from @amplitude/experiment-node-server (Browser SDK 2 format)
and cookie-parser. On sign-up and login, send events with both the request's
device_id and the new user_id, and return amplitudeUserId in the response.
In the browser (@amplitude/unified), call setUserId with that returned value,
never a rebuilt string. Never call reset() or change the device ID during
sign-up or login; call reset() only after sign-out. Never send placeholder
user IDs for anonymous users. Don't invent event names; check the taxonomy
with the Amplitude MCP server if it's connected. Report every file you changed.

What this unlocks

  • Session replay recordings from before sign-up sit on the same profile as Account Created, so you can watch what someone did on the pricing page before they converted.
  • Feature Experiment can bucket a visitor by device ID before sign-up and still credit Account Created to the same person.
  • Guides and surveys can target signed-in users by the plan property the server sets at login, on whichever device they use next.
  • Product analytics funnels from the first page view to Account Created count one user per person instead of breaking at the sign-up form.

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.