Procedure selection
| Procedure | Use |
|---|---|
| publicProcedure | Public boot or health data, with captured-scope checks when supplied |
| protectedProcedure | An authenticated User's personal data |
| orgProcedure | Shared data in the membership-checked Active Organization |
| orgProcedure.use(requireOrgRole(...)) | The same, limited to Members with one of the listed Org roles; available, not applied to any procedure yet |
| adminProcedure | Global reads requiring the current platform admin role |
| adminWriteProcedure | Admin writes through Better Auth's audited capability |
Each feature lives in packages/api/src/features/<feature>/: the leaf contract (<feature>.contract.ts), the procedures (<feature>.procedures.ts), the service and its errors. Register the procedures in platform/rpc/app-router.ts. Browser and mobile clients use the type-only api/client export and the feature's contract subpath (api/<feature>/contract). The backend conventions give the full shape.
Add an operation
- Define input and output schemas, including bounded pagination and text limits.
- Choose the procedure according to the resource owner.
- Apply Organization scope in every relevant query and mutation.
- Keep the mutation and Audit Event in one transaction when the operation is audited.
- Add any cross-package schema export explicitly, then consume it through a hook.
Pagination
Each list endpoint uses one strategy. Choose it by what the interface needs:
| Use | When | Shape | Examples |
|---|---|---|---|
| Offset | The interface shows a total, page numbers or facet counts over a bounded, slowly changing set | page from 1 and pageSize capped at 100; the output carries total and pageCount | todo.list, the admin audit table and user list |
| Cursor | The list grows without bound or changes while it is read (feeds, timelines, "load more") | an opaque cursor of the stable sort key, such as { createdAt, id }, plus limit capped at 100; the output carries the next cursor | Activity Timeline, integration delivery activity, Document Text pages |
| Bounded window | The server caps the result by design | a plain array or a fixed window, no paging | Board lanes (limitPerLane), the Calendar date window, document search |
Rules for every list:
- Every paginated query has a total
ORDER BYwith a unique tie-breaker (id), so equal sort values cannot repeat or skip rows. - Every limit has a maximum in the contract. No endpoint returns an unbounded page.
- A cursor is opaque to clients. A malformed cursor is
INPUT_VALIDATION(HTTP 400), never a 500. - One endpoint offers one strategy; it does not accept both
pageandcursor.
The HTTP endpoint is /api/rpc. The session-authenticated OpenAPI reference is at /api/rpc/api-reference, with its specification at /api/rpc/api-reference/spec.json.
Client state
TanStack Query owns server data, query keys and invalidation. Forms use TanStack Form. URL state expresses Todo views, filters and sorting. Components render the hook's state; they do not recreate authentication or authorization decisions.
Validate external payloads at the boundary. Use the shared error vocabulary and translated resolver rather than returning internal exceptions. Do not create a parallel REST API for a domain operation already represented by oRPC.