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, 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/providers/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/providers/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/providers/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();
  }
}

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.

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

// Avoid — factories are environment-specific.
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 — factories are environment-specific.
import { createServerAnalytics } from 'trakoo';
// Avoid — there is no provider aggregate export.
import { PostHogServerProvider } from 'trakoo/providers';

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?