bunderstackbunderstack/ docs

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.protected

context.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

BaseSession behaviorUse it for
o.publicSession is resolved only if you call context.getSession()Public application behavior
o.protectedResolves the session and narrows context.userUser-owned or private behavior
o.webhookPublic and preserves access to the exact raw bodyProvider 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.

On this page