Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Analytics

Events from the browser and from the server, sent to PostHog and tied to the signed-in user.

@zap-ts/analytics lets the app record what people do. It sends these events to PostHog. PostHog is a product analytics service.

The package has two export paths. @zap-ts/analytics/client is for the browser. It gives you one React component to wrap your app in. @zap-ts/analytics/server is for events the server sends itself. Both talk to the same PostHog project, and both read the same two variables.

The browser side

AnalyticsProvider wraps the app. Both apps/web and apps/marketing use it in their root route.

import { AnalyticsProvider } from "@zap-ts/analytics/client";

const App = () => (
  <AnalyticsProvider>
    <Routes />
  </AnalyticsProvider>
);

Inside, it is the PostHogProvider of @posthog/react. The package sets person_profiles: "identified_only". So PostHog keeps a profile for people you name, and not for every visitor who passes by.

Is POSTHOG_KEY missing? Then the provider just returns its children, and sends nothing. A fresh copy of the project still runs, and no component has to check whether analytics is set up.

Why it needs the authentication package

The provider does one more thing. It keeps the PostHog identity equal to the signed-in user. That is the only reason @zap-ts/analytics depends on @zap-ts/authentication.

A small component inside the provider, IdentifyUser, reads the session. It uses authClient.useSession() from @zap-ts/authentication/client. A session is the record that says who is signed in right now. Then it does one of two things:

  1. Someone is signed in. It calls posthog.identify(userId). Now events belong to that person.
  2. Nobody is signed in. It calls posthog.reset(). So the next person on that browser does not get the events of the last one.

So your sign-in and sign-out code needs no analytics call. The session changes, and the identity follows by itself.

After the provider is in place, components send events with usePostHog from @posthog/react. The package does not wrap that hook.

The server side

The server side is an Effect service. ServerAnalytics is a tag. A tag is a name that stands for a service you provide later. This one holds a posthog-node client. ServerAnalyticsLive is the layer that builds it. A layer is the recipe that makes a service real.

import { capture, ServerAnalyticsLive } from "@zap-ts/analytics/server";
import { Effect } from "effect";

const program = capture({
  distinctId: "user_1",
  event: "subscription_started",
  properties: { plan: "team" },
});

await Effect.runPromise(program.pipe(Effect.provide(ServerAnalyticsLive)));

capture takes an EventMessage from posthog-node. It needs at least two fields: distinctId, which says who did the thing, and event, which says what they did. You can add properties for anything else you want to keep.

Use the same user id on the server and in the browser. PostHog joins the two sets of events by that id.

The layer is scoped. That means it cleans up after itself. It builds the client at the start, and it waits for client.shutdown() at the end. That call sends anything still waiting in the queue. This matters when the process stops soon after the request, because a later background send would never happen.

The layer also sets flushAt: 1 and flushInterval: 0. So each event leaves right away. It does not wait for a group of events.

The server reads its key with requireEnv, and the browser does not. So a missing key is quiet in the browser and an error on the server. A server event is usually one you really meant to send.

Environment

POSTHOG_KEY=
POSTHOG_HOST=https://eu.i.posthog.com

The root .env.schema file declares both. Both are marked as not sensitive, because the browser gets the key anyway.

POSTHOG_KEY must start with phc_, and it is optional. POSTHOG_HOST is required. It points at the EU cloud by default. Is your project in the US? Then use https://us.i.posthog.com. You can also point it at your own proxy.

See Environment to learn how varlock turns those declarations into the typed ENV that the code imports.

Last updated on September 22, 2026

Was this page helpful?