Skip to content
Esc
navigateopen⌘Jpreview
On this page

@stetcms/analytics

The tracking plan every other piece reads from, the browser client, the route handler you mount, and the format events travel in.

@stetcms/analytics is four entry points around one idea: the tracking plan is the single source of truth. The browser client types track() from it, the handler validates against it, and the plugin publishes it to Stet so your content team can build dashboards on events nobody has fired yet.

For how the pieces fit together and what Stet learns about a reader, read the analytics guide. This page is the API.

Entry point Runs in Holds
@stetcms/analytics anywhere defineAnalytics, event, plan validation
@stetcms/analytics/client the browser createAnalytics
@stetcms/analytics/server your server createAnalyticsHandler
@stetcms/analytics/sync build tools syncTrackingPlan

The tracking plan

defineAnalytics

import { defineAnalytics, event } from '@stetcms/analytics';
import { defineStet } from '@stetcms/config';
import { z } from 'zod';

export default defineStet({
  analytics: defineAnalytics({
    events: {
      signup: event({ plan: z.enum(['free', 'paid']) }),
      checkout: {
        started: event(),
        completed: event({ total: z.number(), currency: z.string().optional() }),
      },
    },
  }),
});
PropType
eventsEventsShape

The events your product records, nested into groups.

TypeEventsShape
origin?string

Stet deployment to send to. Defaults to STET_ORIGIN, then the cloud.

Typestring
apiKey?string

Organization API key, server-side only. Defaults to STET_API_KEY, so a plan file stays free of secrets and safe to commit.

Typestring

event

Declares one event and the props it carries. Props are any Standard Schema validator, so the schema library is yours to pick — Zod, Valibot, ArkType.

event(); // no props
event({ total: z.number() }); // a required prop
event({ coupon: z.string().optional() }); // an optional prop

A schema that accepts undefined makes its prop optional at the call site. An event whose props are all optional can be tracked with a bare name.

Nesting and names

Events group by nesting, and nested events track under dot-joined names:

{ signup: event(), checkout: { started: event(), completed: event() } }
// → 'signup', 'checkout.started', 'checkout.completed'

Names are flat on the wire. The nesting exists so a large plan stays readable.

The browser client

createAnalytics

Typed from the plan, and holds no key. It talks only to the route you mounted in your own app.

import { createAnalytics } from '@stetcms/analytics/client';
import type config from '../stet.config';

export const analytics = createAnalytics<(typeof config)['analytics']>({
  endpoint: '/api/analytics',
});

analytics.track('checkout.completed', { total: 42 });

The type parameter reads only a phantom $types field on the plan, so nothing in the plan’s module graph — schemas, secrets, your whole Zod dependency — has to reach the browser.

PropType
endpointstring

Path or URL of the route you mounted the handler on.

Typestring
context?Record<string, unknown>

Props attached to every event in a batch. Your server's values win over these.

TypeRecord<string, unknown>
flushInterval?number

Milliseconds a partial batch waits before being sent.

Typenumber
Default2000
maxBatchSize?number

Send as soon as this many events are queued.

Typenumber
Default20
autoPageviews?boolean

Record a pageview on load and on history navigation.

Typeboolean
Defaulttrue
fetch?typeof fetch

Override the fetch implementation, e.g. in tests.

Typetypeof fetch

Methods

PropType
track(name, props?)?void

Record an event from the plan. A misspelled name, a missing prop, or a prop of the wrong type is a compile error. Never throws and never rejects.

Typevoid
pageview(url?)?void

Record a pageview. Called for you unless autoPageviews is off. Repeating the current URL is ignored, so a redirect or re-render is not two views.

Typevoid
setContext(context)?void

Merge props into the context sent with every later batch.

Typevoid
flush()?Promise<void>

Send anything queued now. Resolves once the request settles.

TypePromise<void>

Batching and delivery

Events queue and go out together: on flushInterval, on maxBatchSize, and on pagehide and visibilitychange so a closing tab still reports. Requests use keepalive, which lets them outlive the page that sent them.

A failed send puts the events back at the front of the queue for the next flush. A page that never gets one drops them, which is the right trade for analytics and the reason track can promise never to throw.

Pageviews in a single-page app

On a site where every navigation is a real page load, there is nothing to do: the client patches history.pushState and listens for popstate, which covers most routers.

If your router navigates without either — or you want the pageview to land after the route resolves rather than before — turn the automatic behaviour off and call pageview() yourself:

export const analytics = createAnalytics<(typeof config)['analytics']>({
  endpoint: '/api/analytics',
  autoPageviews: false,
});

router.subscribe('onResolved', () => analytics.pageview());

The route handler

createAnalyticsHandler

The route you mount in your own app. Events go browser → your backend → Stet, so there is no third-party origin in the page and every reader’s address stays inside infrastructure you control.

import { createAnalyticsHandler } from '@stetcms/analytics/server';
import config from '../stet.config';

export const POST = createAnalyticsHandler(config.analytics, {
  context: async (request) => ({ userId: (await session(request))?.userId }),
});

It takes a plan and returns (request: Request) => Promise<Response> — a plain web handler, so it mounts anywhere that speaks the Fetch API.

PropType
context?Record<string, unknown> | ((request: Request) => Record<string, unknown> | Promise<…>)

Props merged into every batch. Yours win over anything the browser sent, because the browser is the untrusted side. A function is called per request, which is how a session reaches here.

TypeRecord<string, unknown> | ((request: Request) => Record<string, unknown> | Promise<…>)
origin?string

Stet deployment. Defaults to the plan's, then STET_ORIGIN, then the cloud.

Typestring
apiKey?string

Organization API key. Defaults to the plan's, then STET_API_KEY.

Typestring
salt?string

Salt mixed into the visitor digest. Defaults to the request's host, which keeps visitor ids from being comparable across sites you run.

Typestring
onError?(error: unknown) => void

Called when a batch cannot be forwarded.

Type(error: unknown) => void
Defaultconsole.error
fetch?typeof fetch

Override the fetch implementation.

Typetypeof fetch

A server-provided context value of undefined is skipped rather than written, so a missing session reads as “unknown” instead of deleting what the browser told you.

Responses

Status When
200 Accepted: { accepted: n }. Also the answer for bot traffic, with 0
400 Malformed body, or props that failed the plan
405 Anything but POST
500 No API key configured
502 Stet rejected or could not be reached

Automation is answered normally and thrown away: a 4xx would only teach a crawler to retry, and counting it would flatter every number.

validateEvent

The check the handler runs, exported so you can run it yourself.

const result = await validateEvent(plan.events, 'checkout.completed', { total: 42 });
if (!result.ok) {
  throw new Error(formatIssues(result.issues));
}

Declared props are checked with their own schema and replaced by the parsed value. Undeclared props pass through, so adding a prop in the browser never blocks on a deploy of the plan. An unknown event name does not pass.

Publishing the plan

syncTrackingPlan

Run by @stetcms/vite on dev-server start and at the end of a build, and by stet sync. Call it directly only in a pipeline that has neither.

import { syncTrackingPlan } from '@stetcms/analytics/sync';

const { synced } = await syncTrackingPlan({
  events: config.analytics.events,
  apiKey: process.env.STET_API_KEY,
});
PropType
eventsEventsShape

The plan's events.

TypeEventsShape
apiKeystring

Organization API key. Server-side only.

Typestring
origin?string

Stet deployment.

Typestring
Defaulthttps://stetcms.com
fetch?typeof fetch

Override the fetch implementation.

Typetypeof fetch

Replacement, not merge: an event deleted from your code disappears from the list on the next sync, while anything already recorded under that name keeps its history.

The wire format

What the browser posts to your route, and what your route forwards to Stet. You rarely touch these — they are exported for tests and for anything that speaks the protocol without the client.

PropType
WireEvent?{ name, props, timestamp, url?, referrer? }

One event. timestamp is epoch milliseconds, stamped in the browser when the event happened.

Type{ name, props, timestamp, url?, referrer? }
ClientBatch?{ context, events }

The body the browser client POSTs to your route.

Type{ context, events }
IngestBatch?{ context, metadata, events }

The body the handler forwards to Stet, once it has enriched the batch.

Type{ context, metadata, events }
EventMetadata?{ visitor?, country?, region?, city?, browser?, os?, device? }

What the handler derives from the request. Every field is optional: a proxy may strip the headers it comes from, and analytics never fails a request over missing context.

Type{ visitor?, country?, region?, city?, browser?, os?, device? }
parseClientBatch(body)?ParseResult

Validates a batch by hand rather than trusting a cast, because the body is untrusted input from the open internet.

TypeParseResult
MAX_BATCH_EVENTS?number

Events per batch. Anything larger is a client bug or an abusive caller.

Typenumber
Default100

The visitor digest covers the date, so the same reader is a different id tomorrow: uniques are countable within a day and nothing can be joined across days, by anyone. That is what removes the consent banner.

Was this page helpful?