Skip to content
trakoo
Esc
navigateopen⌘Jpreview
On this page

Client vs Server

When to track on the client, when to track on the server, and how the stateful and stateless APIs differ.

trakoo ships two entry points: trakoo/client for the browser and trakoo/server for Node and edge runtimes. They share your event definitions but behave differently. The client is stateful and non-blocking; the server is stateless and must flush before the process exits. This page explains when to reach for each and how to use both together.

At a glance

Client Server
State Stateful — identify() persists Stateless — pass context per call
User context Set once, applied to later events Passed with every track()
Logout Call reset() Nothing to reset
Lifetime One instance per session One instance per request or worker
Delivery Fire-and-forget Await critical events
Shutdown Not required Required in serverless

When to use each

Track on the client for things the browser knows about:

  • Clicks, scrolls, and form interactions
  • Page views and route changes
  • UI state — modals, tabs, feature engagement
  • Non-critical events where a dropped hit is acceptable

Track on the server for things you must not lose or that the browser should not see:

  • API endpoints and request/response events
  • Background jobs, cron tasks, and queue workers
  • Server actions and form actions
  • Critical business events — payments, signups, subscription changes
  • Events carrying server-only context such as IP or auth headers

Client-side tracking

Import from trakoo/client and create one instance for the session.

import { createClientAnalytics } from 'trakoo/client';
import { PostHogClientProvider } from 'trakoo/providers/client';
import type { AppEvents } from './events';

export const analytics = createClientAnalytics<AppEvents>({
  providers: [
    new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY })
  ]
});

Stateful user context

The client remembers the current user. Call identify() once — usually after login — and every later event carries that context until you call reset().

// 1. On login, identify once.
analytics.identify('user-123', {
  email: 'ada@example.com',
  plan: 'pro'
});

// 2. Later events include the user automatically.
analytics.track('button_clicked', { buttonId: 'checkout' });
// Providers receive userId 'user-123' and its traits.

// 3. On logout, clear the state.
analytics.reset();

See Identifying Users for traits, typing, and logout details.

Fire-and-forget

Client tracking is non-blocking. Don’t await it — let events send in the background so the UI stays responsive.

function handleClick() {
  analytics.track('button_clicked', { buttonId: 'cta' });
  navigateTo('/checkout');
}

Awaiting track() on the client makes the user wait on an analytics request for no benefit.

Page views

Call pageView() on navigation. Most framework integrations wire this to the router for you.

analytics.pageView({
  path: window.location.pathname,
  title: document.title,
  referrer: document.referrer
});

See the Next.js and SvelteKit guides for automatic page view tracking.

Server-side tracking

Import from trakoo/server. The server API is stateless, so create an instance per request or per worker rather than sharing one across users.

import { createServerAnalytics } from 'trakoo/server';
import { PostHogServerProvider } from 'trakoo/providers/server';
import type { AppEvents } from './events';

export const serverAnalytics = createServerAnalytics<AppEvents>({
  providers: [
    new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY! })
  ]
});

Stateless user context

There is no identify() that persists. Pass the user with each event, because one instance may handle many users.

await serverAnalytics.track('api_request', {
  endpoint: '/users',
  method: 'POST'
}, {
  userId: 'user-123',
  user: {
    email: 'ada@example.com',
    traits: { plan: 'pro' }
  }
});

// A different request means a different user — pass context again.
await serverAnalytics.track('api_request', {
  endpoint: '/products',
  method: 'GET'
}, {
  userId: 'user-456',
  user: { email: 'grace@example.com' }
});

Await critical events

For events you must not lose, await track() so it completes before you respond.

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

  try {
    const payment = await processPayment(body);

    await serverAnalytics.track('payment_processed', {
      amount: payment.amount,
      transactionId: payment.id
    }, {
      userId: body.userId
    });

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

Shutdown in serverless

Serverless platforms freeze or terminate a function the moment it returns. Providers often batch events and send them asynchronously, so anything still queued when the function ends is lost. Calling shutdown() flushes those queued events and closes connections before the runtime tears the function down.

export async function handler(req, res) {
  await serverAnalytics.track('api_request', { endpoint: req.url });

  await serverAnalytics.shutdown();

  return res.json({ success: true });
}

Non-blocking with waitUntil

For non-critical server events, respond first and let tracking finish in the background. Vercel’s waitUntil keeps the function alive until the promise settles, which also lets you shutdown() after the flush.

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

export async function GET(req: Request) {
  const result = await fetchData();

  waitUntil(
    serverAnalytics
      .track('api_request', { endpoint: req.url })
      .then(() => serverAnalytics.shutdown())
  );

  return Response.json(result);
}

Using both together

Client and server often cover two halves of the same journey. The client captures intent and interaction; the server records the authoritative outcome.

// Client: the user clicked the signup button.
analytics.track('signup_button_clicked', { location: 'hero' });

// Server: the account was actually created.
await serverAnalytics.track('user_signed_up', {
  email: user.email,
  plan: user.plan
}, {
  userId: user.id,
  user: { email: user.email }
});

You get client context — where they clicked, how long they took — alongside server truth: the confirmed signup and accurate user identity.

Import paths

Use the environment-specific entry points. The root trakoo package exports shared types only, and there is no combined trakoo/providers export.

// Correct — browser-safe code only.
import { createClientAnalytics } from 'trakoo/client';
import { PostHogClientProvider } from 'trakoo/providers/client';

// Avoid — the root package exports shared types only.
import { createClientAnalytics } from 'trakoo';
// Avoid — there is no provider aggregate export.
import { PostHogClientProvider } from 'trakoo/providers';
// Correct — Node and edge code only.
import { createServerAnalytics } from 'trakoo/server';
import { PostHogServerProvider } from 'trakoo/providers/server';

// Avoid — the root package exports shared types only.
import { createServerAnalytics } from 'trakoo';
// Avoid — there is no provider aggregate export.
import { PostHogServerProvider } from 'trakoo/providers';

Framework patterns

'use server';

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

export async function createUser(formData: FormData) {
  const user = await register(formData.get('email') as string);

  await serverAnalytics.track('user_signed_up', {
    email: user.email
  }, {
    userId: user.id
  });

  await serverAnalytics.shutdown();
}
import { serverAnalytics } from '$lib/server-analytics';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request }) => {
  const body = await request.json();

  await serverAnalytics.track('user_signed_up', {
    email: body.email
  }, {
    userId: body.userId
  });

  await serverAnalytics.shutdown();

  return new Response('OK');
};
import { serverAnalytics } from '@/lib/server-analytics';
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  await serverAnalytics.track('user_created', {
    email: req.body.email
  }, {
    userId: req.body.userId
  });

  await serverAnalytics.shutdown();

  res.status(200).json({ success: true });
}

See the Next.js and SvelteKit guides for end-to-end setup.

Next steps

Was this page helpful?