Next.js
Mount Dotyc in a Next.js app — ingest route with store and waitUntil, client emitter, trusted server-side tracking.
Next.js is the reference integration: one app that emits from the client, ingests on its own domain, and tracks from server code in-process. By the end of this guide you have a packages/analytics package, a first-party /api/ingest endpoint, typed track() calls in components, and a server action emitting a trusted, durable business fact that skips HTTP entirely.
What you're building
packages/analytics/
├── events.ts → defineEvent × N + defineCatalog (shared, client-safe)
├── emitter.ts → createEmitter({ catalog, transport, identity })
├── wiring.ts → createWiring(catalog).use(…) (server only)
├── ingest.ts → createDispatcher (server only)
└── adapters/ → custom adaptersThe split matters: events.ts and emitter.ts are client-safe (no secrets, no adapter SDKs). Everything under wiring.ts, ingest.ts and adapters/ is server-only — that's where API keys live.
pnpm add dotyc @dotyc/posthog @dotyc/clerkThis guide uses Clerk for identity; swap in another provider package or manual identify() if that's not your stack.
1. Define the catalog
Every event of your product, in one place. Properties use any Standard Schema validator — Zod, Valibot, ArkType…
// packages/analytics/events.ts
import { defineEvent, defineCatalog } from 'dotyc'
import * as v from 'valibot'
const featureUsed = defineEvent('feature_used', {
tags: ['feature'],
properties: v.object({
feature: v.string(),
}),
})
const projectCreated = defineEvent('project_created', {
tags: ['feature', 'marketing'],
delivery: 'durable', // a business fact: at-least-once, never lost
source: 'trusted', // only emittable in-process — the client type excludes it
properties: v.object({
template: v.string(),
}),
})
export const catalog = defineCatalog({
featureUsed,
projectCreated,
})defineCatalog checks name uniqueness and anchors type inference: event names and tags become literal unions used everywhere downstream. feature_used keeps the defaults (best-effort, anonymous) — fine for product analytics. project_created is a business fact, so it's declared durable and trusted.
2. Wire consumers
Register consumers with declarative match criteria (tags, events, not). Tags and event names are typed from the catalog — a typo is a compile error.
// packages/analytics/wiring.ts
import { createWiring } from 'dotyc/ingest'
import { posthog } from '@dotyc/posthog'
import { catalog } from './events'
export const wiring = createWiring(catalog)
.use(posthog({ apiKey: process.env.POSTHOG_KEY! }), {
tags: ['feature', 'marketing'],
})Any future event tagged feature flows to PostHog with zero wiring edits.
3. Create the dispatcher
The dispatcher is the pure core of ingestion: schema resolution, strict validation, identity resolution, durable persistence, then parallel isolated fan-out to matching adapters. Because the catalog contains a durable event, it needs a store — the outbox behind at-least-once delivery, which also backs identify and replay dedupe:
// packages/analytics/ingest.ts
import { createDispatcher } from 'dotyc/ingest'
import { clerkIdentity } from '@dotyc/clerk/server'
import { catalog } from './events'
import { wiring } from './wiring'
export const dispatcher = createDispatcher({
catalog,
wiring,
identity: clerkIdentity(), // resolves the Clerk session → verified identity
store: redisStore(process.env.REDIS_URL!), // memoryStore() in dev — never per-instance memory in prod
onError: (err, ctx) => console.error('[dotyc]', err),
})On serverless, per-instance memory doesn't survive between invocations — the shared store is what makes durability and dedupe real on Vercel.
memoryStore() is for development only.4. Mount the route handler
toHandler wraps the dispatcher in a fetch-standard (Request) => Response handler — exactly what an App Router route exports. On Vercel, pass waitUntil so fan-out and adapter flushes can finish after the response instead of being killed when the function freezes:
// app/api/ingest/route.ts
import { toHandler } from 'dotyc/ingest'
import { waitUntil } from '@vercel/functions'
import { dispatcher } from '@acme/analytics/ingest'
export const POST = toHandler(dispatcher, {
origin: ['https://app.example.com'],
maxBodySize: '256kb',
waitUntil,
})That's your first-party ingestion endpoint: your domain, invisible to ad-blockers, secrets never leave the server. A guard hook is also available for custom rate limiting, and the response acks every event individually (accepted | rejected | legacy). If you want different rate limits or scaling for business facts and analytics noise, mount the same dispatcher on a second route — see the dual-mount pattern.
5. Create the client emitter
The emitter is deliberately dumb: it knows the catalog and a transport, nothing else. Batching and sendBeacon on unload are defaults for best-effort events; durable events get their own immediately-flushed lane — everything is a setting.
// packages/analytics/emitter.ts
import { createEmitter, http } from 'dotyc'
import { clerkIdentity } from '@dotyc/clerk/client'
import { catalog } from './events'
export const dotyc = createEmitter({
catalog,
transport: http('/api/ingest', {
batchSize: 20,
flushInterval: 5_000,
}),
identity: clerkIdentity(), // reads the current Clerk user, subscribes to changes
})With clerkIdentity() on both sides, the client attaches an identity claim and the ingest verifies it against the real session — the verified version always wins.
6. Track from a component
track() is typed by inference from the catalog:
// app/(app)/gantt/toolbar.tsx
'use client'
import { dotyc } from '@acme/analytics/emitter'
export function GanttToolbar() {
return (
<button onClick={() => dotyc.track('feature_used', { feature: 'gantt_view' })}>
Gantt view
</button>
)
}dotyc.track('feature_used', { feature: 'gantt_view' }) // ✅
dotyc.track('feature_used', {}) // ❌ compile error
dotyc.track('typo_event', {}) // ❌ compile error
dotyc.track('project_created', { template: 'kanban' }) // ❌ compile error: source 'trusted'The last one matters: project_created is source: 'trusted', so the client emitter's type simply doesn't offer it. A trusted fact cannot be emitted (or forged) from a browser — the guarantee is enforced at compile time, and the HTTP ingest rejects it as a backstop.
7. Emit the trusted fact from the server
Server code (route handlers, server actions, crons) uses the same catalog and the same wiring — but skips HTTP with the direct transport, which dispatches in-process. That's the only emitter allowed to carry trusted events:
// packages/analytics/emitter.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/actions/projects.ts
'use server'
import { dotycServer } from '@acme/analytics/emitter.server'
export async function createProject(input: CreateProjectInput) {
const project = await db.insert(projects).values(input)
dotycServer.track('project_created', { template: input.template }) // ✅ trusted: OK via direct()
return project
}The fact is emitted where it happens — the server action that actually creates the project — so there is nothing to verify. Being durable, it is persisted to the store's outbox and retried per consumer until acknowledged; a PostHog hiccup can't lose it.
Same event definitions, same routing, same adapters — one pipeline whether the event originates in a browser or in a server action.
Keep the import boundary clean: client components import only from events.ts and emitter.ts. Importing wiring.ts or ingest.ts into a client bundle would drag adapter SDKs and secrets with it.
Where to go next
- Events as business facts — drive quotas and billing from durable trusted events.
- Wiring — matching semantics in depth.
- Identity — how claims, verification and reconciliation work.
Adapters
The consumers: a minimal name + handle() contract, an optional lifecycle, guaranteed isolation — and six conventional roles that turn a wiring into an app's event backbone.
Events as business facts
Drive usage counting, quotas and billing from the same events as your analytics — durably and from a trusted source.