Skip to content
trakoo
Esc
↑↓navigate↵open⌘Jpreview
On this page

Identifying Users

Attach user context to your events so analytics tells you who did what — stateful in the browser, per-request on the server.

Identification connects events to the person who triggered them. Without it, you see anonymous actions; with it, you see journeys.

Anonymous            Identified
─────────            ──────────
Someone upgraded  →  ada@example.com (Pro) upgraded
Someone viewed pricing  →  ada@example.com viewed pricing
Someone checked out  →  ada@example.com started Enterprise checkout

That context is what powers funnels, cohorts, retention, and per-user segmentation downstream.

How you attach it depends on where you’re tracking. In the browser, trakoo is stateful — identify once and it remembers. On the server, it’s stateless — pass context with each call. Client vs Server covers that split in depth; this page is the how-to for user context specifically.

Client-side: identify once

Call identify() when you learn who the user is — usually right after login. Every later event carries that user automatically, no extra arguments needed.

import { analytics } from '@/lib/analytics';

analytics.identify('user_123', {
  email: 'ada@example.com',
  name: 'Ada Lovelace',
  plan: 'pro'
});

// User context is attached to everything that follows.
await analytics.track('button_clicked', { buttonId: 'upgrade' });
// Providers receive: { userId: 'user_123', email: 'ada@example.com', traits: { name, plan } }

Good moments to identify:

  • After a successful login or signup.
  • On app load, when a session already exists.

Reset on logout

Clear the stored user when the session ends, so the next person’s events aren’t attributed to the last one.

async function handleLogout() {
  await logout();
  analytics.reset();
}

Server-side: pass context per call

The server holds no session state. Attach user context as the third argument to track() — it applies to that event only.

import { serverAnalytics } from '@/lib/server-analytics';

await serverAnalytics.track('project_created', {
  name: 'Analytics dashboard'
}, {
  userId: 'user_123',
  user: {
    email: 'ada@example.com',
    traits: { plan: 'pro', company: 'Acme' }
  }
});

await serverAnalytics.shutdown();

Each call stands alone, so a server instance can safely handle many users. There’s no persisted user state to reset.

A server identify() takes the request it came from as an optional third argument. Providers that place a profile by where its user is, such as OpenPanel, read the IP and user agent from it; without them the profile lands where your server runs.

await serverAnalytics.identify(user.id, { email: user.email }, {
  context: {
    server: {
      ip: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(),
      userAgent: request.headers.get('user-agent') ?? undefined
    }
  }
});

Type your traits

Pass a typed<T>() trait marker to type-check identify() alongside your events.

import { createClientAnalytics } from 'trakoo/client';
import { typed } from 'trakoo';
import { appEvents } from './events';

interface UserTraits {
  email: string;
  name: string;
  plan: 'free' | 'pro' | 'enterprise';
  company?: string;
  role?: 'admin' | 'member' | 'viewer';
}

export const analytics = createClientAnalytics({
  events: appEvents,
  userTraits: typed<UserTraits>(),
  providers: [/* ... */]
});

analytics.identify('user_123', {
  email: 'ada@example.com',
  name: 'Ada Lovelace',
  plan: 'pro',      // autocompleted and checked
  role: 'admin'
  // wrong: true    // compile error — 'wrong' is not a UserTrait
});

Guidelines

Use a stable user ID. Pick an identifier that never changes — a database ID or UUID — and use the same format everywhere. Emails and session IDs change, which fragments a user’s history across identities.

analytics.identify('550e8400-e29b-41d4-a716-446655440000', {
  email: 'ada@example.com',
  name: 'Ada Lovelace',
  plan: 'pro'
}); // Good
analytics.identify('ada@example.com', { /* ... */ }); // Avoid — email can change

Keep traits current. Re-identify with the same ID when a user’s data changes; providers merge the update.

async function handleUpgrade(user: User, plan: UserTraits['plan']) {
  await upgradePlan(user.id, plan);
  analytics.identify(user.id, {
    email: user.email,
    name: user.name,
    plan
  });
}

Send only segmentation data, never secrets. Plans, roles, company, and signup date are useful. Passwords, tokens, full card numbers, and government IDs must never be sent to a provider.

Keep personal data where it’s needed. Route analytics providers with pii: false so they get the user id but never the email or name, while email tools still receive them. See personal data routing.

Framework patterns

Identify from your auth provider so it happens once per session, wherever the user becomes known.

'use client';

import { useEffect } from 'react';
import { analytics } from '@/lib/analytics';

export function AuthProvider({ user, children }: { user: User | null; children: React.ReactNode }) {
  useEffect(() => {
    if (user) {
      analytics.identify(user.id, {
        email: user.email,
        name: user.name,
        plan: user.plan
      });
    } else {
      analytics.reset();
    }
  }, [user]);

  return <>{children}</>;
}

Resolve the user from the request and pass context with each event.

import { serverAnalytics } from '@/lib/server-analytics';

export async function POST(req: Request) {
  const user = await getCurrentUser(req);
  const body = await req.json();

  await serverAnalytics.track('project_created', { name: body.name }, {
    userId: user.id,
    user: { email: user.email, traits: { plan: user.plan } }
  });

  await serverAnalytics.shutdown();
  return Response.json({ ok: true });
}

Debugging

Turn on debug to log identification and the context attached to each event.

const analytics = createClientAnalytics({
  events: appEvents,
  providers: [/* ... */],
  debug: true
});

analytics.identify('user_123', { email: 'ada@example.com', plan: 'pro' });
// [Analytics] User identified: user_123 { email: 'ada@example.com', plan: 'pro' }

Next steps

Was this page helpful?