Quickstart
From install to events flowing into PostHog and your own usage counter — a best-effort analytics event from the client, a durable trusted business fact from the server.
By the end of this page, two events flow through one catalog: a best-effort analytics event tracked from a React component into PostHog, and a durable, trusted business fact emitted from the API route that performs the action, feeding your own account usage counter. One definition each, routed by the same wiring. The example uses Next.js, but the ingest handler is fetch-standard and mounts the same way in Hono, Remix, Bun, and friends.
1. Install
Dotyc lives in a dedicated package of your monorepo, conventionally packages/analytics:
pnpm add dotyc @dotyc/posthog zoddotyc is the client-safe core (defineEvent, defineCatalog, createEmitter); dotyc/ingest is the server entry point. Bring any Standard Schema validator — Zod here, but Valibot or ArkType work identically.
2. Define your events
One file, every event of your product. Names, typed properties, free-form tags used for routing later — and, per event, a delivery class and a source:
// packages/analytics/events.ts
import { defineEvent, defineCatalog } from 'dotyc'
import { z } from 'zod'
// Product analytics: fire-and-forget from the client. Losing one is acceptable.
const featureUsed = defineEvent('feature_used', {
tags: ['feature'],
// delivery: 'best-effort' and source: 'anonymous' are the defaults
properties: z.object({
feature: z.string(),
}),
})
// Business fact: drives the usage counter (and, one day, billing).
// durable = at-least-once delivery; trusted = can only be emitted server-side, in-process.
const projectCreated = defineEvent('project_created', {
tags: ['feature', 'domain'],
delivery: 'durable',
source: 'trusted',
properties: z.object({
projectId: z.string(),
}),
})
const checkoutCompleted = defineEvent('checkout_completed', {
tags: ['marketing', 'domain'],
delivery: 'durable',
source: 'trusted',
properties: z.object({
plan: z.string(),
}),
})
export const catalog = defineCatalog({
featureUsed,
projectCreated,
checkoutCompleted,
})defineCatalog checks name uniqueness and anchors type inference: from here on, event names and tags are literal unions everywhere.
The rule of thumb baked into this catalog: any event with monetary consequence is delivery: 'durable', source: 'trusted' — emitted where the fact happens (the server route that performs the action), never claimed by the client. See Delivery guarantees.
3. Create the client emitter
The client emitter is client-safe and knows only the catalog and a transport — no adapter SDKs, no secrets.
// packages/analytics/emitter.ts
import { createEmitter, http } from 'dotyc'
import { catalog } from './events'
export const dotyc = createEmitter({
catalog,
transport: http('/api/ingest'),
// defaults: batching by size/interval + sendBeacon on page unload
})Because project_created is source: 'trusted', it is excluded from this emitter's track() type: trying to track it from the client is a compile error, not a runtime rejection.
4. Write a custom adapter
The canonical Dotyc move: business logic consuming the same events as your analytics. A minimal adapter is a name and a batch-aware handle — five lines.
// packages/analytics/adapters/usage-counter.ts
import { db } from '@acme/db'
export const usageCounter = {
name: 'usage-counter',
async handle(events) {
for (const event of events) {
await db.incrementAccountUsage(event.identity.accountId, event.name)
}
},
}handle only ever receives events that passed strict catalog validation. Because durable delivery is at-least-once, an adapter counting money-adjacent things should be idempotent — every event carries a stable eventId (ULID) for exactly that.
5. Wire the consumers
Routing is declared server-side, once, by matching on tags and event names:
// packages/analytics/wiring.ts
import { createWiring } from 'dotyc/ingest'
import { posthog } from '@dotyc/posthog'
import { catalog } from './events'
import { usageCounter } from './adapters/usage-counter'
export const wiring = createWiring(catalog)
.use(posthog({ apiKey: process.env.POSTHOG_KEY! }), { tags: ['feature', 'marketing'] })
.use(usageCounter, { tags: ['domain'] })Both matchers are typed from the catalog: a typo in a tag or event name is a compile error.
6. Mount the ingest route
The dispatcher is the pure core; toHandler wraps it in a fetch-standard (Request) => Response handler. Because the catalog contains durable events, the dispatcher needs a store — the pluggable brick that persists the outbox, retry state, and dedupe caches:
// packages/analytics/ingest.ts (server only)
import { createDispatcher, memoryStore } from 'dotyc/ingest'
import { catalog } from './events'
import { wiring } from './wiring'
export const dispatcher = createDispatcher({
catalog,
wiring,
store: memoryStore(), // dev only — use a Redis/Postgres/KV store in production
onError: (err) => console.error('[dotyc]', err),
})// app/api/ingest/route.ts
import { toHandler } from 'dotyc/ingest'
import { dispatcher } from '@acme/analytics/ingest'
export const POST = toHandler(dispatcher)Your ingestion is now first-party: same domain, your endpoint, invisible to ad-blockers. In dev, rejected events are surfaced loudly in the console — no silent drops while you iterate.
memoryStore() keeps state in instance memory: fine on your laptop, wrong in production — especially serverless, where each invocation may be a fresh instance. Ship with an official Redis, Postgres, or KV store driver.7. Emit the business fact where it happens
The trusted event is emitted by the code that performs the action — the API route — through the direct transport: same catalog, same wiring, no HTTP hop.
// packages/analytics/server.ts
import { createEmitter } from 'dotyc'
import { direct } from 'dotyc/ingest'
import { catalog } from './events'
import { dispatcher } from './ingest'
export const dotycServer = createEmitter({
catalog,
transport: direct(dispatcher),
})// app/api/projects/route.ts
import { dotycServer } from '@acme/analytics/server'
export async function POST(request: Request) {
const project = await createProject(request)
// The fact is emitted where it happened — not claimed by the client.
dotycServer.track('project_created', { projectId: project.id })
return Response.json(project)
}The server does not verify a client claim after the fact — it is the emitter. That is the whole trust model for business facts.
8. Track analytics from a component
The client keeps doing what clients are good at: best-effort product analytics. track() is fully typed by inference:
'use client'
import { dotyc } from '@acme/analytics/emitter'
export function GanttToggle() {
return (
<button onClick={() => dotyc.track('feature_used', { feature: 'gantt_view' })}>
Open Gantt view
</button>
)
}dotyc.track('feature_used', { feature: 'gantt_view' }) // ok
dotyc.track('feature_used', {}) // compile error: missing `feature`
dotyc.track('project_created', { projectId: 'p_1' }) // compile error: trusted event, server-onlyClick the button: feature_used batches to your endpoint and fans out to PostHog. Create a project: project_created is persisted to the outbox, acked, then fans out to PostHog and your usage counter — retried with backoff if the counter fails. Two events, one catalog, one wiring.
Where to go next
Introduction
Dotyc is an open-source analytics abstraction SDK: define a typed event catalog once, route every event to N consumers — with per-event delivery guarantees so analytics and billing share the same truth.
The four stages
Catalog → emitter → ingest → adapters. Why full decoupling, who trusts whom, and which delivery guarantees hold where.