API Procedures
Typed application behavior with oRPC and Standard Schema
Generated CRUD, files, realtime, and your own behavior live in one oRPC graph. Declare the builder once at module scope, then write router modules that import the bases they need.
Declare the builder
defineApi takes the values you already have and infers the types from them.
It reads nothing at runtime, so a module can call it at import time.
// src/api/base.ts
import { defineApi } from 'bunderstack'
import { envSchema } from './env'
import { schema } from './schema'
export const o = defineApi({ schema, env: envSchema })
export const publicProcedure = o.public
export const protectedProcedure = o.protectedcontext.db is typed from schema, and context.env from envSchema. You do
not write the generic parameters yourself.
Write router modules
A router is a plain object. Import the base, export the object:
// src/api/boards.ts
import * as v from 'valibot'
import { protectedProcedure } from './base'
export const boardsRouter = {
stats: protectedProcedure
.route({ method: 'GET', path: '/api/board-stats', tags: ['boards'] })
.input(v.object({ boardId: v.string() }))
.handler(async ({ context, input }) => {
const rows = await loadBoardTodos(context.db, input.boardId)
return {
total: rows.length,
done: rows.filter((row) => row.done).length,
}
}),
}Collect the modules and pass the object to bunderstack:
// src/api/index.ts
import { boardsRouter } from './boards'
import { projectsRouter } from './projects'
export const api = { boards: boardsRouter, projects: projectsRouter }const backend = bunderstack({ schema, database, api })
const app = await backend.start()The client receives api.boards.stats.call(),
api.boards.stats.queryOptions(), and the inferred result type. The .route()
call also exposes the same procedure as ordinary HTTP.
api also accepts a callback — api: (o) => ({ … }) — for a router that must
be built from the framework builder at configuration time. For everything else
the object form keeps router modules free of factory wrappers.
Procedure bases
| Base | Session behavior | Use it for |
|---|---|---|
o.public | Session is resolved only if you call context.getSession() | Public application behavior |
o.protected | Resolves the session and narrows context.user | User-owned or private behavior |
o.webhook | Public and preserves access to the exact raw body | Provider callbacks and signed events |
Every handler context contains typed db and env, plus storage, email,
jobs, realtime, auth, request, resHeaders, getSession(),
peekSession(), and getRawBody().
Extend a base
A base is an oRPC builder, so .use() gives you your own. Declare it next to
the others and import it like any other base:
// src/api/base.ts
export const adminProcedure = o.protected.use(
async ({ context, next, errors }) => {
if (context.user.role !== 'admin') {
throw errors.FORBIDDEN({ message: 'Admin access required' })
}
return next()
},
)A middleware can also add to the context. Whatever you pass to next is
merged, and later handlers see it typed:
export const orgProcedure = o.protected.use(
async ({ context, next, errors }) => {
const organizationId = context.session.activeOrganizationId
if (!organizationId) {
throw errors.FORBIDDEN({ message: 'No active organization' })
}
return next({ context: { organizationId } })
},
)
// context.organizationId is a string here, with no extra annotation.
export const membersRouter = {
list: orgProcedure.handler(({ context }) =>
listMembers(context.db, context.organizationId),
),
}To apply a middleware to every procedure, including generated CRUD, storage and realtime, register it in the config instead. See Middleware.
Input and output schemas
.input(schema) accepts any Standard Schema implementation. Bunderstack
examples use Valibot, but the framework does not require it from applications.
The handler's return value is the default output type. You do not need to write an output validator for every procedure.
Use .output(schema) when runtime output validation has a concrete purpose:
- the value crosses a third-party trust boundary;
- output transformation is required;
- a precise response schema is important in generated OpenAPI.
const result = v.object({ id: v.string(), title: v.string() })
createPost: protectedProcedure
.input(v.object({ title: v.pipe(v.string(), v.minLength(1)) }))
.output(result)
.handler(async ({ context, input }) => createPost(context, input))This is output validation by choice, not ceremony.
Typed errors
Every procedure carries one declared error map. Raise from it with the errors
argument, which handlers and middleware both receive:
.handler(async ({ context, input, errors }) => {
const board = await findBoard(context.db, input.id)
if (!board) throw errors.NOT_FOUND({ message: 'Board not found' })
return board
})The codes are BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND,
CONFLICT, PAYLOAD_TOO_LARGE, and TOO_MANY_REQUESTS. Each maps to its
standard HTTP status, and the client can narrow on them with the oRPC
isDefinedError helpers. Extra context goes in data.details:
throw errors.CONFLICT({
message: 'Generation already running',
data: { details: { adaptationId } },
})Do not construct ORPCError by hand. Code outside a handler — a service
function or a job — has no errors argument, so throw BunderstackError
there; the framework maps it to the same typed error:
import { BunderstackError } from 'bunderstack'
export async function spendCredits(db: Db, userId: string, amount: number) {
const balance = await readBalance(db, userId)
if (balance < amount) {
throw new BunderstackError('FORBIDDEN', 'Insufficient credits')
}
}Errors thrown by framework facilities use the same contract, so clients do not need a second error model for generated CRUD.
List endpoints outside CRUD
Generated CRUD lists already support filters, sorting, cursors, and counts.
listSpec gives the same contract to a procedure you write yourself — an admin
view over a table that is not exposed as CRUD, for example:
import { listSpec } from 'bunderstack'
const logsList = listSpec(appLogs, {
filterable: ['level', 'action', 'userId'],
sortable: ['createdAt'],
defaultSort: { column: 'createdAt', order: 'desc' },
})
export const adminRouter = {
logs: adminProcedure.input(logsList.input).handler(logsList.handler),
}The response is a ListResult:
{ items, hasMore, nextCursor, total, limit, offset, sort, order }. Pass
count: true in the input to receive total.
listSpec returns the schema and the handler separately rather than a finished
procedure. That keeps your base procedure concrete at the call site, which is
what preserves the row type all the way to the client. It reads no access
configuration: the base procedure carries the policy.
Typing helpers that take the database
A service function in its own module cannot reach typeof app.db without an
import cycle. Use the exported types instead:
import type { BunderstackDb, BunderstackTx } from 'bunderstack'
import type { schema } from './schema'
type Db = BunderstackDb<typeof schema>
type Tx = BunderstackTx<typeof schema>
export async function transfer(db: Db, from: string, to: string) {
await db.transaction(async (tx: Tx) => {
/* … */
})
}Organizing larger APIs
Group by domain. Nesting is ordinary oRPC router shape:
export const api = {
projects: projectsRouter,
billing: { invoices: invoicesRouter, plans: plansRouter },
}Procedure names may not collide with generated tables or reserved namespaces
such as files, realtime, and health; collisions fail at startup.
OpenAPI
Set openapi: true to serve /api/openapi.json. Route metadata and Standard
Schema inputs are projected into the document. RPC remains the source of type
safety; OpenAPI is useful for mobile code generation, external consumers, and
API inspection without becoming a second implementation.
See HTTP & Webhooks for detailed HTTP inputs, signature verification, and raw responses.