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