Getting Started
Build and call a typed Bunderstack API in five minutes
Install
bun add bunderstack drizzle-orm valibot @libsql/client @tanstack/react-query
bun add -d drizzle-kitDefine a schema
Use the Drizzle builders directly and re-export Bunderstack's internal tables so storage and runtime metadata are included in migrations.
// schema.ts
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export * from 'bunderstack/schema'
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
userId: text('userId').notNull(),
createdAt: integer('createdAt', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
})Better Auth also needs its user, session, account, and verification
tables. Copy the matching SQLite or Postgres definitions from an example or
the SaaS template.
Create the app
The custom procedure below joins the same graph as generated CRUD. Valibot is used for runtime input validation; the handler return type becomes the output contract automatically.
// bunderstack.ts
import { bunderstack } from 'bunderstack'
import { libsql } from 'bunderstack/libsql'
import { provision } from 'bunderstack/provision-schema'
import { count } from 'drizzle-orm'
import * as v from 'valibot'
import { posts } from './schema'
import * as schema from './schema'
export const backend = bunderstack({
schema,
database: { adapter: libsql() },
access: {
posts: {
ownerColumn: 'userId',
searchableColumns: ['title'],
sortableColumns: ['createdAt', 'id'],
},
},
realtime: true,
api: (o) => ({
postCount: o.public
.input(v.optional(v.object({})))
.handler(async ({ context }) => {
const [row] = await context.db.select({ value: count() }).from(posts)
return { value: row?.value ?? 0 }
}),
}),
})
export const app = await backend.start()
export type App = typeof app
await provision(app)The declaration is one object and it is pure: nothing connects until
backend.start(). database.url is omitted here because it defaults to
DATABASE_URL. When a slot does need a value — an API key, a custom URL —
database, storage, messaging, and realtime also accept a function of
the validated environment, and auth accepts a builder over { db, env }.
This getting-started flow uses the development-only schema push from
bunderstack/provision-schema, which requires Drizzle Kit. Before deployment,
run bunx drizzle-kit generate, commit the journal, and switch the import to
bunderstack/provision. The production entrypoint only applies committed
migrations and never imports Drizzle Kit. Skip it when deployment manages
migrations outside the application.
Serve one handler
// server.ts
import { app } from './bunderstack'
Bun.serve({ fetch: app.handler })Generated procedures are callable through typed RPC and ordinary HTTP:
GET /api/posts
POST /api/posts
PATCH /api/posts/:id
DELETE /api/posts/:id
POST /api/rpc/postCount
GET /api/openapi.json when openapi: trueCall it from the client
Only the App type crosses the boundary; server code is not bundled.
// api-client.ts
import { QueryClient } from '@tanstack/react-query'
import { createClient } from 'bunderstack/query'
import type { App } from './bunderstack'
export const queryClient = new QueryClient()
export const api = createClient<App>({ queryClient })
const page = await api.posts.list.call({ limit: 20 })
const total = await api.postCount.call({})
// page and total are inferred from the server graphDatabase choices
Import exactly one adapter. Its dialect must match your Drizzle tables.
| Database | Adapter | Optional peer |
|---|---|---|
| SQLite / Turso | bunderstack/libsql | @libsql/client |
| Embedded Postgres | bunderstack/pglite | @electric-sql/pglite |
| Postgres on Bun | bunderstack/bun-sql | none |
| Postgres on Node | bunderstack/postgres-js | postgres |
Continue with Auto CRUD, then add application behavior in API Procedures.