Skip to content
trakoo
Esc
↑↓navigate↵open⌘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, while delivery and shutdown depend on instance ownership and provider behavior. 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 Application-owned session instance Application-owned request, worker, or process instance
Delivery Returns a promise; often handled in the background Await critical events
Shutdown Not required Match the pair’s owner, runtime, and provider

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/posthog/client';
import { appEvents } from './events';

export const analytics = createClientAnalytics({
  events: appEvents,
  providers: [
    new PostHogClientProvider({ token: import.meta.env.VITE_POSTHOG_KEY })
  ]
});

The factory returns a fresh instance. trakoo has no global client singleton or module-level tracking helper; your application owns this instance and imports it where needed.

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.
await 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.

Handle the tracking promise

Client track() always returns Promise<void>. UI flows often start that work without delaying navigation, but should explicitly handle a possible rejection. Rejections can matter when strict validation is configured or initialization fails.

function handleClick() {
  void analytics
    .track('button_clicked', { buttonId: 'cta' })
    .catch((error) => reportTrackingFailure(error));
  navigateTo('/checkout');
}

Await track() when completion matters or when the caller should handle a strict validation failure inline:

async function trackBeforeContinuing() {
  try {
    await analytics.track('button_clicked', { buttonId: 'cta' });
    continueWorkflow();
  } catch (error) {
    reportTrackingFailure(error);
  }
}

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: user state is never retained between calls. Instance lifetime is a separate ownership decision based on the provider’s behavior and the runtime’s lifetime.

import { createServerAnalytics } from 'trakoo/server';
import { PostHogServerProvider } from '@trakoo/posthog/server';
import { appEvents } from './events';

export function createRequestAnalytics() {
  return createServerAnalytics({
    events: appEvents,
    providers: [
      new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY! })
    ]
  });
}

Each request creates a fresh analytics/provider pair, uses it inside try, and shuts down that same pair in finally.

import { createServerAnalytics } from 'trakoo/server';
import { PostHogServerProvider } from '@trakoo/posthog/server';
import { appEvents } from './events';

export const processAnalytics = createServerAnalytics({
  events: appEvents,
  providers: [
    new PostHogServerProvider({ apiKey: process.env.POSTHOG_API_KEY! })
  ]
});

A long-running application may own one stateless pair. Pass context on every call and shut it down only during application or process teardown, not after a request.

Stateless user context

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

await processAnalytics.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 processAnalytics.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.

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

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

  try {
    const payment = await processPayment(body);

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

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

Events that happened earlier

A server event is stamped with the time of its track() call. When you record one after the fact, such as in a backfill, from a queue drained later, or as a projection of stored records, pass occurredAt so the event carries the time it happened:

await analytics.track('order_completed', {
  orderId: order.id,
  amount: order.total
}, {
  userId: order.userId,
  occurredAt: order.completedAt // a Date, or epoch milliseconds (not seconds)
});

An occurredAt that is neither a valid Date nor a number of milliseconds a Date can hold fails validation: invalid_options in argument three, as here, and invalid_properties in a propertyless event’s argument two. OpenPanel, PostHog and Bento record the event at that time; Pirsch and EmitKit record it on receipt and keep the time only as a property. What a provider does with an old event is its own: see its page. Only server analytics takes occurredAt.

Groups

Server analytics can attribute events to groups, such as the company or workspace a user acts for. group() creates or updates a group and, given a userId, adds that user to it. track()’s groups option attributes one event to its groups, by group type:

await analytics.group('company', company.id, { plan: company.plan }, {
  userId: user.id
});

await analytics.track('report_exported', { format: 'csv' }, {
  userId: user.id,
  groups: { company: company.id }
});

Each group type and id must be a non-empty string; anything else fails validation with invalid_options. Only providers that implement groups receive them: OpenPanel’s server provider does, and every other provider ignores group() and the groups option. A provider routed with pii: false receives a group’s traits without name, email and the other personal keys. Route group like any other method with methods or exclude. Groups are server-only: a server provider is shared by every request, so nothing about a group is remembered between calls, and each event names its own.

Revenue

Server analytics records revenue with revenue(). Pass the amount in the currency’s minor unit (cents for EUR and USD), and attribute it to the user and the groups who paid:

await analytics.revenue(4900, { planId: 'pro', kind: 'renewal' }, {
  currency: 'EUR',
  id: invoice.id,
  userId: user.id,
  user: { email: user.email },
  groups: { company: company.id },
  occurredAt: invoice.paidAt
});

The amount must be a non-negative safe integer, currency three upper-case letters (an ISO 4217 code; whether it exists is not checked), and id and userId non-empty strings; anything else, or an option revenue() does not know, fails validation with invalid_options. Properties must be a plain object without currency, which is an option so that every provider reads a checked one; otherwise the call fails with invalid_properties. Under the default validation policy an invalid call is dropped without a word, so the likeliest mistake, 49.99 where 4999 was meant, disappears: set validation.onError or debug while you wire it up. A refund is not negative revenue: track it as an event of its own. user (or context.user, or defaultContext.user) carries the payer’s email for providers that need it, as in track().

A provider that fails is logged and the others still deliver, as in track(), so revenue() resolves. That matters in a payment webhook: a rejection would fail the webhook, the payment provider would retry it, and providers that do not deduplicate would count the revenue twice. id names the payment, such as its invoice, for providers that deduplicate by it. Only providers that implement revenue receive it, each in its own form: OpenPanel’s server provider sends OpenPanel’s revenue event, PostHog’s captures a revenue event for its revenue analytics, and Bento’s tracks a $purchase toward the subscriber’s lifetime value (it requires an email, a currency and an id). Route it with methods or exclude under the name revenue; the events and excludeEvents filters do not apply to it. Revenue is server-only, so a browser never decides what was paid.

Shutdown in serverless

Serverless platforms may freeze or terminate a function after it returns. When a request owns its analytics/provider pair, shut that pair down before the request ends. The exact effect is provider-specific: shutdown may flush queued work, clear state, close resources, or combine those behaviors.

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

export async function handler(req, res) {
  const analytics = createRequestAnalytics();

  try {
    await analytics.track('api_request', { endpoint: req.url });

    return res.json({ success: true });
  } finally {
    await analytics.shutdown();
  }
}

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 the scheduled task shut down its request-owned pair.

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

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

  waitUntil(
    analytics
      .track('api_request', { endpoint: req.url })
      .finally(() => analytics.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.
await analytics.track('signup_button_clicked', { location: 'hero' });

// Server: a process-owned instance records the authoritative outcome.
await processAnalytics.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 plus environment-neutral helpers such as defineEvents, typed, and noProperties. Factories stay on trakoo/client and trakoo/server, and there is no combined trakoo/providers export. Provider packages such as @trakoo/posthog follow the same split and have no root export: import from their /client or /server subpath.

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

// Avoid — factories are environment-specific.
import { createClientAnalytics } from 'trakoo';
// Avoid — provider packages have no root export.
import { PostHogClientProvider } from '@trakoo/posthog';
// Correct — Node and edge code only.
import { createServerAnalytics } from 'trakoo/server';
import { PostHogServerProvider } from '@trakoo/posthog/server';

// Avoid — factories are environment-specific.
import { createServerAnalytics } from 'trakoo';
// Avoid — provider packages have no root export.
import { PostHogServerProvider } from '@trakoo/posthog';

Async validation and delivery order

Schema validation may be asynchronous. If you start several track() calls concurrently, a later call whose validator finishes first can reach providers first. Concurrent calls are not delivery-ordered:

// Both start immediately; provider delivery order is not guaranteed.
await Promise.all([
  processAnalytics.track('first_event', firstInput),
  processAnalytics.track('second_event', secondInput)
]);

When order matters, await each call before starting the next. Validation failures use their own configured policy; initialization failures and provider failures retain their existing behavior.

Framework patterns

'use server';

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

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

  try {
    await analytics.track('user_signed_up', {
      email: user.email
    }, {
      userId: user.id
    });
  } finally {
    await analytics.shutdown();
  }
}
import { createRequestAnalytics } from '$lib/server-analytics';
import type { RequestHandler } from './$types';

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

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

    return new Response('OK');
  } finally {
    await analytics.shutdown();
  }
};
import { createRequestAnalytics } from '@/lib/server-analytics';
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const analytics = createRequestAnalytics();

  try {
    await analytics.track('user_created', {
      email: req.body.email
    }, {
      userId: req.body.userId
    });

    res.status(200).json({ success: true });
  } finally {
    await analytics.shutdown();
  }
}

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

Next steps

Was this page helpful?