Dotyc
Guides

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 adapters

The 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/clerk

This 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.

Draft — subject to change: the exact store interface and the store driver packages (Redis / Postgres / KV) are not finalized. 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

On this page