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'
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(), url: 'file:./data.db' },
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)provision(app) pushes the schema while there is no migrations journal. Once
you run bunx drizzle-kit generate and commit the migrations, the same call
only applies pending migrations. 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.