Skip to main content
apps/api is the full-stack backend. One source tree hosts three entry points that share the same code — resources, schemas, the @/db service:
ServiceEntryJob
APIsrc/app.tssrc/server.tsThe HTTP server: a Hono app serving the oRPC router
Schedulersrc/scheduler.tsA standalone process that runs crons and background jobs — see Scheduler
Migratorscripts/migrate.tsApplies pending Drizzle migrations against PostgreSQL — see Migrator
The stack is Hono · oRPC · Drizzle ORM · PostgreSQL · better-auth · Socket.IO. No controllers, no route registry — the filesystem is the wiring.

A resource owns everything

Every business entity is a folder under apps/api/src/resources/<name>/. The resource owns its table, its schemas, its endpoints, its side effects and its gates:
resources/users/
  users.schema.ts          # Drizzle table + Zod schemas
  endpoints/               # oRPC endpoints, mounted by file path
    list.ts                # GET  /users
    current.get.ts         # GET  /users/current
    [userId]/update.ts     # PUT  /users/{userId}
  methods/                 # reusable business logic
  handlers/                # mutation-event handlers (side effects)
  middlewares/             # per-resource gates (e.g. can-edit-*.ts)
There’s one place for each thing, so you — and your agents — never guess where code lives. See How Ship works for the full model.

Endpoints are oRPC

Every endpoint builds on the shared @/endpoint builder — the oRPC builder with all global middlewares already applied — and declares its input, output and handler:
resources/users/endpoints/list.ts
import db from '@/db';
import endpoint from '@/endpoint';
import isAdmin from '@/middlewares/is-admin';
import { listResultSchema, paginationSchema } from '@/resources/base.schema';
import { publicSchema } from '../users.schema';

export default endpoint
  .use(isAdmin)
  .input(paginationSchema)
  .output(listResultSchema(publicSchema))
  .handler(async ({ input }) => {
    return db.users.findPage({ where: { deletedAt: null }, ...input });
  });
.input() / .output() are Zod schemas — the single source of truth for the runtime and the types the client sees. Gates compose with .use(...). The handler returns a value; oRPC serialises it. See Routing and Middlewares.

Contract-first, generated from the filesystem

oRPC separates a thin contract (route methods + paths) from the router (the implementations). Ship generates both from your endpoint files so you never hand-edit a route table:
pnpm --filter api codegen
scripts/codegen-router.ts walks resources/**/endpoints/ and writes:
  • src/contract.ts — the oc.router(...) contract, one oc.route({ method, path }) per endpoint file.
  • src/router.tsimplement(contract).router({...}) wiring each endpoint into its slot, plus the exported AppClient type the web app consumes.
list.tsGET /users, create.tsPOST, [userId]/update.tsPUT /users/{userId}. The route table is the directory tree.

Data access is a typed service

scripts/codegen-db.ts generates a DbService per table, exported from @/db — a thin, typed wrapper over Drizzle with a uniform filter API:
await db.users.findPage({
  where: { deletedAt: null, email: { ilike: '%@acme.com' } },
  orderBy: { createdAt: 'desc' },
  page: 1,
  perPage: 20,
});
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 a typed MutationEvent; drop a handler in <resource>/handlers/ to react. The DbService lives in the @ship/db package; see How Ship works for the full data-access model.

Migrations

Drizzle owns the schema lifecycle:
pnpm --filter api generate    # drizzle-kit emits SQL into apps/api/drizzle/
pnpm --filter api migrate     # apply pending migrations (scripts/migrate.ts)
pnpm --filter api db:push     # push the schema directly (dev only)
See Migrator for the full flow.

Auth and context

better-auth resolves the session on every request. server.ts builds an ORPCContext and calls serverConfig.resolveUser, which reads the better-auth session from the request headers and attaches the row to context.user when signed in:
const session = await auth.api.getSession({ headers: ctx.rawRequest.headers });
if (session?.user) {
  ctx.user = await db.users.findFirst({ where: { id: session.user.id } });
}
From there, the isAuthorized and isAdmin gates read context.user. The full auth surface — sign-in/up, verification, reset, Google OAuth, plus the web pages — is delivered by the Auth plugin (see Plugins).

Interactive API reference

In non-production, Hono serves a live Scalar UI in-process. server.ts generates an OpenAPI 3.1 spec from the router and renders the docs with an interactive “try it out”: Scalar API reference
Browse your tables with Drizzle Studio too — run pnpm dashboard and open https://local.drizzle.studio.