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.
The parts
Section titled “The parts”| 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.
How a request travels
Section titled “How a request travels”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.
The web app
Section titled “The web app”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)insrc/lib/api.tsis the only way to call the API, and it checks every answer against the schema. Auth calls go throughauthClient.useWorkspace()gives the current workspace withcan('admin')for the role andhas('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.