@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() }),
},
},
}),
});
eventsEventsShape
The events your product records, nested into groups.
EventsShapeorigin?string
Stet deployment to send to. Defaults to STET_ORIGIN, then the cloud.
stringapiKey?string
Organization API key, server-side only. Defaults to STET_API_KEY, so a plan file stays free of secrets and safe to commit.
stringevent
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.
endpointstring
Path or URL of the route you mounted the handler on.
stringcontext?Record<string, unknown>
Props attached to every event in a batch. Your server's values win over these.
Record<string, unknown>flushInterval?number
Milliseconds a partial batch waits before being sent.
number2000maxBatchSize?number
Send as soon as this many events are queued.
number20autoPageviews?boolean
Record a pageview on load and on history navigation.
booleantruefetch?typeof fetch
Override the fetch implementation, e.g. in tests.
typeof fetchMethods
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.
voidpageview(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.
voidsetContext(context)?void
Merge props into the context sent with every later batch.
voidflush()?Promise<void>
Send anything queued now. Resolves once the request settles.
Promise<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.
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.
Record<string, unknown> | ((request: Request) => Record<string, unknown> | Promise<…>)origin?string
Stet deployment. Defaults to the plan's, then STET_ORIGIN, then the cloud.
stringapiKey?string
Organization API key. Defaults to the plan's, then STET_API_KEY.
stringsalt?string
Salt mixed into the visitor digest. Defaults to the request's host, which keeps visitor ids from being comparable across sites you run.
stringonError?(error: unknown) => void
Called when a batch cannot be forwarded.
(error: unknown) => voidconsole.errorfetch?typeof fetch
Override the fetch implementation.
typeof fetchA 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,
});
eventsEventsShape
The plan's events.
EventsShapeapiKeystring
Organization API key. Server-side only.
stringorigin?string
Stet deployment.
stringhttps://stetcms.comfetch?typeof fetch
Override the fetch implementation.
typeof fetchReplacement, 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.
WireEvent?{ name, props, timestamp, url?, referrer? }
One event. timestamp is epoch milliseconds, stamped in the browser when the event happened.
{ name, props, timestamp, url?, referrer? }ClientBatch?{ context, events }
The body the browser client POSTs to your route.
{ context, events }IngestBatch?{ context, metadata, events }
The body the handler forwards to Stet, once it has enriched the batch.
{ 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.
{ 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.
ParseResultMAX_BATCH_EVENTS?number
Events per batch. Anything larger is a client bug or an abusive caller.
number100The 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.