Skip to main content
Ship has a single organising idea: a resource owns everything about itself, and the filesystem is the wiring. There’s one place for each thing, so you — and your agents — never have to guess where code lives or how it’s connected.

A resource owns everything

Each API resource is a folder under apps/api/src/resources/<name>/:
resources/users/
  users.schema.ts        # Drizzle table + Zod schemas
  endpoints/             # oRPC endpoints, mounted by file path
    list.ts              # GET  /users
    [userId]/update.ts   # PUT  /users/{userId}
  methods/               # reusable business logic
  handlers/              # mutation-event handlers (side effects)
  crons/                 # scheduled jobs — scheduler({ cron, handler })
  middlewares/           # per-resource gates (e.g. can-edit-*.ts)
File-based mounting means the route table is the directory tree. list.tsGET, create.tsPOST, [userId]/update.tsPUT /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:
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 });
  });
Validation is the contract: .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:
GateGuarantees
isAuthorizeda signed-in context.user
isAdminthe 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:
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 events

Every write publishes a typed MutationEvent ({ 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 codebaseapps/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 / useApiForm hooks (provided by the Auth plugin).
  • Web-only — call server functions (createServerFn) directly from a route loader. Backend logic, no separate API.
Components avoid useEffect (there’s a skill enforcing it) and colocate private pieces in -components/ folders the router ignores.

Why this matters for agents

Standardised patterns mean a coding agent reading your repo finds exactly one way to add an endpoint, a table, a route or a side effect — and the codegen and type flow guarantee the wiring it generates is real. Less to infer, less to get wrong. That’s what AI-native means in Ship: the structure is the prompt.