Dotyc
Guides

Client-only apps

Using Dotyc without a backend — JWT-verified identity, durable client events, and the limits of trust.

Dotyc's design rule is never force the server. If your app is a SPA or a static site with no backend of its own — auth handled by Clerk, Supabase or similar, data behind third-party APIs — you can still get typed events, declarative routing, verified identity, and even at-least-once delivery for events that matter. This guide explains how the pieces fit, what a client-only app cannot do, and what remains on the roadmap.

What "never force the server" means

Several architectural choices exist specifically so a client-only app is a first-class citizen:

  • Context travels with the emitter. The event's context and identity claim (anonymousId, sessionId, claimed userId, traits) are carried by the client, not resolved from a server session. A server-side enrichment hook can add to or override this when a session exists — but nothing requires one.
  • Identity verification works from a JWT. You don't need a server session to trust userId. Every major auth provider (Clerk, Supabase Auth, Neon Auth, Better Auth) issues JWTs; the ingest verifies the token the client already holds.
  • The ingest brick is pure and portable. createDispatcher is framework-free with no imposed I/O, and toHandler produces a fetch-standard (Request) => Response function. Any edge or worker runtime that speaks Web Request/Response can host it — you don't need "a backend", you need one deployed function.

Identity without a server session

The trust model is unchanged from full-stack apps: what the client declares is a claim; what the ingest resolves is the truth. In a client-only app, the resolution path is the JWT.

On the emitter side, plug your auth provider so identity is attached automatically:

// 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('https://ingest.example.com/ingest'),
  identity: clerkIdentity(),   // reads the current user, subscribes to changes
})

On the ingest side, the same provider package verifies the JWT from the request and produces a verified identity that overrides the claim:

// ingest/dispatcher.ts
import { createDispatcher, toHandler } from 'dotyc/ingest'
import { clerkIdentity } from '@dotyc/clerk/server'
import { catalog } from './events'
import { wiring } from './wiring'

const dispatcher = createDispatcher({
  catalog,
  wiring,
  identity: clerkIdentity(),   // verifies the JWT → verified identity
})

export const handler = toHandler(dispatcher, {
  origin: ['https://app.example.com'],
  maxBodySize: '256kb',
})

If your auth isn't covered by an official provider package, the current draft plans a generic jwtIdentity({ jwks }) that verifies any JWT against your JWKS endpoint, plus a customIdentity(fn) escape hatch.

Once verified, the usual reconciliation applies: the verified userId is the pivot that links the device's anonymousId and sessionId, and that link is signaled to adapters so your destinations can merge anonymous and identified activity.

What a client-only app can and cannot do

The delivery / source axes draw the line precisely.

You CAN have durable client events. delivery: 'durable' works from a browser: the emitter flushes durable events immediately in their own envelopes and retries until the ingest acks them, and the ingest persists them to its store's outbox before acknowledging — at-least-once from the client. Use it for facts your product cares about that legitimately originate client-side: a document saved in a local-first editor, a file export completed in the browser. Give your ingest function a store (Redis/KV — never instance memory on an edge runtime) and declare the event durable.

You CANNOT emit trusted events. source: 'trusted' events require a trusted runtime: they are only emittable in-process via direct(dispatcher), are rejected if they arrive over HTTP, and are excluded from the client emitter's track() type — a compile error, by design. A browser can never be that runtime: anything it sends is a claim. With no backend of your own, there is nowhere today to run the code that emits a trusted fact — which is exactly the gap the future hosted ingest is meant to close, by giving client-only apps a trusted runtime operated by Dotyc.

Monetary facts still need trusted. The rule holds regardless of architecture: any event with a monetary consequence is delivery: 'durable', source: 'trusted'. If your client-only app has billable actions, the honest answer is that those facts should be emitted by whatever system actually executes the action (your payment provider's webhook receiver, a third-party API's callback — each a small function that can host direct()), not tracked from the browser.

Durable ≠ trusted. Durable answers "can this event be lost?" (no — at-least-once). Trusted answers "can this event be forged?" (no — it never crosses the public network). A client-only app gets the first guarantee, never the second.

Deploying the ingest as a single function

Until hosted ingest ships, the ingest brick still has to run somewhere — but "somewhere" is one fetch-standard function, not an application server. The handler above deploys as-is to any runtime with Web-standard Request/Response:

// e.g. a Cloudflare Worker
import { handler } from './ingest/dispatcher'

export default {
  fetch: (request: Request) => handler(request),
}

Point it at a subdomain of your site (ingest.example.com) and you keep the first-party benefits: same-site cookies for anonymousId, no ad-blocker interference, and your adapter API keys living only in that function's environment.

The emitter never carries adapter SDKs or secrets, so your client bundle stays clean regardless of how many destinations the wiring fans out to.

Roadmap: hosted ingest

For apps that don't want to operate even one function, the plan is a Dotyc-hosted ingest: the exact same open-source brick — same catalog, same wiring contract, same strict validation — operated by Dotyc. Because identity verification is JWT-based, verified identity works there too, without you running anything. It is also the path by which client-only apps would eventually gain a trusted runtime for source: 'trusted' events.

Hosted ingest is a roadmap item, not an available service. Today, self-hosting the ingest function (edge function, worker, or any route in an existing app) is the supported path. The SDK itself is and remains 100% open source.

Recap

PieceClient-only answer
Typed eventsdefineEvent / defineCatalog — pure TypeScript, runs anywhere
EmissioncreateEmitter + http() transport — batching and sendBeacon defaults
Durable eventsYes — at-least-once from the client (emitter retry lane + ingest outbox with a shared store)
Trusted eventsNo — they need a trusted runtime (direct()); hosted ingest is the future path
Identity claimEmitter-side provider (clerkIdentity(), …) or manual identify()
Identity truthJWT verified at ingest (provider packages or generic JWKS, per current draft)
IngestOne fetch-standard function on an edge runtime — hosted ingest later

On this page