bunderstackbunderstack/ docs

Query Client

An oRPC and TanStack Query client inferred from your app

bunderstack/query derives generated CRUD and application procedures from typeof app. There is no code generation, route list, or duplicated response interface.

Create the client

bun add bunderstack @tanstack/react-query
// bunderstack.ts — server
export const backend = bunderstack({ schema, access, api: (o) => ({}) })
export const app = await backend.start()
export type App = typeof app
// api-client.ts — client
import { QueryClient } from '@tanstack/react-query'
import { createClient } from 'bunderstack/query'
import type { App } from './bunderstack'

export const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000 } },
})

export const api = createClient<App>({ queryClient })

App is a type-only import, so server code and the Drizzle schema do not enter the browser bundle.

Direct calls

Every procedure exposes .call(input):

const page = await api.posts.list.call({ limit: 20 })
const post = await api.posts.get.call({ id: postId })
const created = await api.posts.create.call({ title: 'Typed boundaries' })
const stats = await api.stats.call({ boardId })

Inputs and outputs are inferred from the unified oRPC graph. Generated table procedures and custom procedures use the same API shape.

TanStack Query

Read procedures expose .queryOptions(). Write procedures expose .mutationOptions().

import { useMutation, useQuery } from '@tanstack/react-query'

function Posts() {
  const posts = useQuery(api.posts.list.queryOptions({ input: { limit: 20 } }))
  const createPost = useMutation(api.posts.create.mutationOptions())

  return (
    <button onClick={() => createPost.mutate({ title: 'Hello' })}>
      {posts.data?.items.length ?? 0} posts
    </button>
  )
}

Pass standard TanStack Query callbacks and options into the option factory:

api.posts.create.mutationOptions({
  onSuccess: (created) => console.log(created.id),
})

Loaders and prefetching

export const Route = createFileRoute('/')({
  loader: () =>
    queryClient.ensureQueryData(
      api.posts.list.queryOptions({ input: { limit: 20 } }),
    ),
})

Files

Declared buckets receive small helpers in addition to their procedures:

const uploaded = await api.files.images.upload(file)
const thumbnail = api.files.images.url(uploaded.fileId, {
  w: 320,
  format: 'webp',
})
await api.files.images.delete(uploaded.fileId)

Realtime query caches

Use syncRealtime when the application uses TanStack Query without TanStack DB collections:

import { syncRealtime } from 'bunderstack/query'

const connection = syncRealtime({
  api,
  queryClient,
  tables: ['posts', 'comments'],
})

// call connection.close() when the application client is disposed

The client applies typed events to matching caches and invalidates subscribed tables after reconnect. Connection retry, resume, and dead-stream detection are internal.

Changes reach the cache in one batch per animation frame, so a burst of writes costs one cache write and one invalidation per query key instead of one of each per event. Pass notifyScheduler to change that: 'sync' writes as each event arrives, and a number debounces by that many milliseconds.

syncRealtime({ api, queryClient, tables: ['posts'], notifyScheduler: 'sync' })

Set apply: 'patch' to write changes into cached lists instead of invalidating them, so a write costs one request rather than two. A list is patched only when membership and ordering can be settled locally, and invalidated otherwise.

Type utilities

import type { InferSchema, InferSelect } from 'bunderstack/query'

type Schema = InferSchema<App>
type Post = InferSelect<Schema['posts']>

These aliases are useful at component boundaries. Avoid restating response types that the procedure client already knows.

On this page