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, theamplitudeIdentitymiddleware, androutes/auth.js. This page replaces the middleware,routes/auth.js, andserver.js. - A frontend that initializes
@amplitude/unifiedwithinitAllusing the same project API key as the server, as on the React product analytics page. - Node.js 20.6 or later and
"type": "module"inpackage.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.
// 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.
2. Read the device ID from the header or the Amplitude cookie
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:
npm install @amplitude/experiment-node-server cookie-parserThis file replaces 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:
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:
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.
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 });
}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:
- Network tab. The
POST /auth/signuprequest carriesx-amplitude-device-id, and its response includesamplitudeUserId. Filter forapi2.amplitude.com: the next browser upload after sign-up has thatuser_idand the samedevice_idas the uploads before it. - Process logs. With
logLevelat Debug outside production, the terminal prints thetrackcall forAccount Createdwith bothuser_idanddevice_idin its arguments, then the upload within about 10 seconds. - 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.
- 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 Viewedevents from before sign-up, thenAccount 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.
// -- 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.
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 Createdto the same person. - Guides and surveys can target signed-in users by the
planproperty the server sets at login, on whichever device they use next. - Product analytics funnels from the first page view to
Account Createdcount one user per person instead of breaking at the sign-up form.
One integration; everything else reads from the same events.
Next steps
Reference docs
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.