Skip to content

Overview

This is the map. The other pages in this section go deeper into auth, workspaces and billing; for a feature built end to end, see Add a resource.

Where What it is
packages/contracts Zod schemas for everything that crosses the network, plus plans and app settings
apps/api NestJS on Bun, Postgres through Drizzle, Prisma or TypeORM, Better Auth at /api/auth
apps/web React with TanStack Router and Query, shadcn/ui
apps/mobile Expo, calling the same API with the same contracts
apps/site The Astro marketing site; it only links to the app
packages/deploy deploy:init and deploy:check
.railway Your production on Railway, as code
docs These pages

Contracts come first. The API validates requests with a schema from packages/contracts, and the web and mobile apps check responses with the same schema. Change a field and every app that uses it fails to compile, rather than failing in front of a customer. Types are z.infer of a schema, never written by hand, and there are no DTO classes.

In production the browser only ever talks to the web app’s address. Caddy serves the app and passes /api/* to the API over the private network, so cookies are first-party and there is no CORS. Locally, Vite’s dev server does the same.

In the API, a request to a workspace route passes these checks in order:

Step Enforced by Fails with
1. Is anyone signed in? AuthGuard, global. @Public() skips it 401
2. Is this route for operators only? AuthGuard, with @AdminOnly() 403
3. Is the caller a member of this workspace? WorkspaceGuard, with @WorkspaceScoped() 404
4. Is their role high enough? WorkspaceGuard, with @RequireRole() 403
5. Does the plan include the feature? WorkspaceGuard, with @RequireFeature() 402
6. Is the input valid? The global pipe, with @Body({ schema }) 400

Then the controller makes one call to a service, and the service queries Postgres with Drizzle, Prisma or TypeORM, whichever you chose: see Database and ORMs. The guards decide who reaches the route; only the service’s queries decide which rows come back, so every query on workspace data filters by the workspace’s id. See Workspaces and teams.

Requests under /api/auth go straight to Better Auth, before any of this: sign-in, sessions, workspaces, members and invitations are its endpoints, configured in apps/api/src/auth/auth.ts. See Auth and sessions.

Anything slow or that talks to another service leaves the request as a job on the queue in Postgres, email included. See Background jobs.

Every signed-in page lives under apps/web/src/routes/_authenticated/w.$slug/, in both modes: with teams off, the browser shows /app/... instead of /w/<slug>/....

  • api(path, schema, options) in src/lib/api.ts is the only way to call the API, and it checks every answer against the schema. Auth calls go through authClient.
  • useWorkspace() gives the current workspace with can('admin') for the role and has('auditLog') for the plan. These only decide what is shown; the API enforces the same rules on its own.
  • A page sets its title with staticData: { title }; the shell renders the heading and the browser tab.

API tests in apps/api/test boot the real app against a separate <name>_test database. createTestApp({ teams }) runs it in either mode, and can stand in for Stripe (stripe) or Polar (polar), the AI model (model) and your plans (plans). t.signUp() returns a signed-in client, and every email lands in t.outbox instead of being sent. Nothing runs in the background: queued jobs run when a test asks for them. See Test your features.