A resource owns everything
Each API resource is a folder underapps/api/src/resources/<name>/:
list.ts → GET, create.ts → POST, [userId]/update.ts → PUT /users/{userId}. No route registry to maintain.
Endpoints are oRPC, not controllers
Every endpoint builds on the shared@/endpoint builder — the oRPC builder with all global middlewares already applied — and declares its input, output and handler:
.input()/.output() Zod schemas are the single source of truth for the runtime and the types the client sees.
Gates compose with .use()
Authorization is middleware you stack onto an endpoint — no scattered if checks:
| Gate | Guarantees |
|---|---|
isAuthorized | a signed-in context.user |
isAdmin | the user is an admin |
canAccess(key, load) | loads an entity into context[key] or throws NOT_FOUND |
canEdit(key, service) | the current user owns the entity (or NOT_FOUND — no existence leak) |
canEdit is built on canAccess; per-resource ownership rules live in <resource>/middlewares/can-edit-*.ts.
Data access is a typed service
codegen-db.ts generates a DbService per table, exported from @/db. It’s a thin, typed wrapper over Drizzle with a uniform filter API:
find / findFirst / findPage / count / insertOne / insertMany / updateOne / updateMany / deleteOne / deleteMany, plus db.transaction(...) and relation loading via with / columns. Every table extends baseColumns — a uuid id, createdAt, updatedAt, and a deletedAt soft-delete column.
Mutations emit events
Every write publishes a typedMutationEvent ({ type, docs, prevDocs }). Drop a handler in <resource>/handlers/ to react — sync to analytics, push over a socket, denormalise — without coupling it to the endpoint that caused the change.
Types flow without codegen
The web app never imports a generated client. The API emits TypeScript declarations (pnpm --filter api build:types); the web app imports import type { AppClient } from 'api' through its workspace:* dependency. Change an endpoint’s .output() and the web app’s types update on the next build — no shared types package, no drift.
Everything is a plugin
Auth, file storage, email, AI chat and the admin panel are plugins, not framework internals. A plugin is a folder of resources, routes and packages that merges into your codebase —apps/api/src/resources/..., apps/web/src/routes/..., packages/.... Install copies the files in; from then on they’re yours to edit, extend or delete. Like shadcn/ui, but for your entire stack. See Plugins.
The web app mirrors the model
apps/web is a TanStack Start SPA with file-based routes (src/routes/**). Two data-access patterns, depending on the shape:
- Full-stack — consume the API with the typed oRPC client and the
useApiQuery/useApiMutation/useApiFormhooks (provided by the Auth plugin). - Web-only — call server functions (
createServerFn) directly from a route loader. Backend logic, no separate API.
useEffect (there’s a skill enforcing it) and colocate private pieces in -components/ folders the router ignores.
