Client SDK
A fully typed client for your app's API, call createClient once with your generated AppConfig and get inferred CRUD, globals, custom routes, search, file uploads, and live realtime queries, all derived from your server schema.
The client SDK is a single typed object that talks to your app's HTTP API. Call createClient<AppConfig>() once with a baseURL, and you get client.collections.*, client.globals.*, client.routes.*, client.search, and client.realtime, every method, argument, and return shape inferred from the same AppConfig that codegen produced from your server. A typo in a collection name or a where field is a compile error; the JSON you get back is the real record shape.
The same client runs in the browser, in a Node/Bun script, in a worker, or in a test. It sends cookies on every request (credentials: "include"), so your Better Auth session flows through automatically.
What it does
- One typed surface for the whole API.
client.collections.posts.find(...),client.globals.settings.get(),client.routes.admin.stats(...), collections, globals, and custom routes are all inferred fromAppConfig. - Inferred everything. Collection names,
whereoperators,withrelation hydration, create/update inputs, and return shapes all come from your schema. No hand-written interfaces to drift. - Live queries built in.
collections.posts.live(...)andliveIter(...)stream access-controlled snapshots over one multiplexed SSE connection, same result type asfind(). - Uploads with progress.
collections.media.upload(file, { onProgress })posts multipart with anXMLHttpRequestso you get real progress events and cancellation. - Search and realtime first-class.
client.search.search(...)hits your search adapter;client.realtimeis the low-level stream thelive()helpers build on. - Framework adapters. The server handler is mounted with one line via
questpieNext,questpieHono, orquestpieElysia, the client'sbasePathjust has to match.
Quick start
Create the client once and export it. Import createClient from questpie/client, and the generated AppConfig type from your app, AppConfig is what makes every call typed.
import { createClient } from "questpie/client";
import type { AppConfig } from "#questpie"; // generated by `questpie generate`
export const client = createClient<AppConfig>({
// required, origin of your app's API
baseURL:
typeof window !== "undefined"
? window.location.origin
: process.env.APP_URL || "http://localhost:3000",
basePath: "/api", // must match your server handler's basePath
});Then call it anywhere:
import { client } from "@/lib/client.js";
// List, returns a paginated envelope, not a bare array.
const { docs, totalDocs } = await client.collections.posts.find({
where: { published: true },
orderBy: { createdAt: "desc" },
limit: 10,
});
// Read one by id (optimized to GET /api/posts/:id).
const post = await client.collections.posts.findOne({ where: { id } });
// Create, `data` is typed from the collection's insert shape.
const created = await client.collections.posts.create({
title: "Hello",
slug: "hello",
published: true,
});`AppConfig` is a type, not a value
createClient<AppConfig>(...) takes the generated AppConfig (typeof app) as a type parameter, there is no runtime app passed to the client. Run questpie generate after changing your schema so AppConfig (and therefore every client method) stays in sync. The runtime is a Proxy, so any string key returns a callable surface even when the type rejects it, unknown collection/route names are compile-time errors only, not runtime guards.
Configuration
createClient<AppConfig>(config) takes a single QuestpieClientConfig:
export type QuestpieClientConfig = {
baseURL: string; // required, e.g. "http://localhost:3000"
basePath?: string; // default "/", "/api" for fullstack apps
fetch?: typeof fetch; // default globalThis.fetch
headers?: Record<string, string>; // default headers on every request
useSuperJSON?: boolean; // default true
};| Option | Default | What it does |
|---|---|---|
baseURL (required) | none | Origin the client requests against. Combined with basePath to form every URL. |
basePath | "/" | API mount path. Normalized: a leading slash is added if missing, a trailing slash stripped. Must match the server handler's basePath. |
fetch | globalThis.fetch | Custom fetch (e.g. an instrumented one, or a server-side polyfill). |
headers | {} | Default headers merged into every request. accept-language / Accept-Language here seeds the initial locale. |
useSuperJSON | true | Serializes request/response bodies with SuperJSON so Date, Map, Set, and BigInt survive the wire. Adds the X-SuperJSON header. |
createClient returns the QuestpieClient<AppConfig> object:
{
collections; // typed CRUD per collection
globals; // typed get/update per global
routes; // typed callers for your custom routes
search; // search + reindex
realtime; // low-level SSE stream API
setLocale; getLocale; getBasePath; // locale + base-path helpers
}`basePath` must match the server, and `'/'` reads back as `''`
The client's basePath and the server handler's basePath (createFetchHandler({ basePath }), questpieNext(app, { basePath }), …) must be identical or requests 404. Use "/" for a server-only API, "/api" for a fullstack app that also serves a frontend. getBasePath() returns the normalized path, an empty string "" when basePath is "/", not "/".
Collections
client.collections.<name> is a typed CRUD object, the same vocabulary as the server, but by id on the client (the server's update/delete are bulk-by-where; here updateById/deleteById are the single-row operations, and updateMany/deleteMany are the bulk ones). Every read accepts the full query language (where, with, columns, orderBy, limit, offset, search, locale, …); every mutation takes an optional trailing locale-options argument.
Read
// find, paginated. Returns { docs, totalDocs, totalPages, page, hasNextPage, ... }.
const result = await client.collections.posts.find({
where: { published: true, author: { is: { role: "editor" } } },
with: { author: true }, // hydrate the relation
orderBy: { createdAt: "desc" },
limit: 20,
offset: 0,
});
// findOne, first match or null. Optimized: a where of ONLY { id } hits GET /:id.
const post = await client.collections.posts.findOne({ where: { id } });
const bySlug = await client.collections.posts.findOne({ where: { slug: "hello" } });
// count, bare number (the { count } envelope is unwrapped for you).
const total = await client.collections.posts.count({ where: { published: true } });find() serializes its options into the query string (qs, skipNulls, bracket arrays) and resolves to a PaginatedResult:
// await client.collections.posts.find({ limit: 2 })
{
"docs": [ { "id": "01J…", "title": "Hello", "published": true }, { "id": "01J…", "title": "World", "published": false } ],
"totalDocs": 2,
"limit": 2,
"totalPages": 1,
"page": 1,
"pagingCounter": 1,
"hasPrevPage": false,
"hasNextPage": false,
"prevPage": null,
"nextPage": null
}`find()` returns an envelope; `groupBy` changes its shape
Read your rows off result.docs, never result itself. Pass groupBy and the result becomes a GroupedPaginatedResult instead, { groups: [{ key, value, count, docs }], totalGroups, ... }, where limit/offset paginate the groups. Both shapes are inferred from your options.
Create, update, delete
// create(data, options?), `data` is the collection's insert shape; supports nested relations.
const created = await client.collections.posts.create({
title: "Hello",
slug: "hello",
published: true,
author: { connect: { id: authorId } }, // belongsTo: raw id OR { connect: { id } }
tags: { create: [{ name: "intro" }] }, // hasMany / M:N: connect | create | connectOrCreate | set
});
// updateById({ id, data }, options?), by id on the client. `update` is the alias.
const updated = await client.collections.posts.updateById({
id: created.id,
data: { published: false },
});
// deleteById({ id }), soft if softDelete is enabled. Returns { success }.
await client.collections.posts.deleteById({ id: created.id });
// restoreById({ id }), only on softDelete collections.
await client.collections.posts.restoreById({ id: created.id });`updateById` / `deleteById` are canonical; `update` / `delete` are aliases
On the client, update({ id, data }) and delete({ id }) operate on a single row by id, they are aliases of updateById / deleteById, kept so the vocabulary reads the same. This is the opposite of the server CRUD, where update / delete are bulk-by-where. Prefer the …ById names in app code to avoid confusion.
Bulk writes
// updateMany({ where, data }), one `data` applied to every matching row. Returns the written rows.
const winners = await client.collections.posts.updateMany({
where: { published: false },
data: { published: true },
});
// updateBatch({ updates }), distinct data per row, all in one transaction.
await client.collections.posts.updateBatch({
updates: [
{ id: a, data: { title: "A" } },
{ id: b, data: { title: "B" } },
],
});
// deleteMany({ where }), bulk delete by filter. Returns { success, count }.
const { count } = await client.collections.posts.deleteMany({
where: { published: false },
});`updateMany` / `deleteMany` are claim-checked
Bulk operations lock the matching rows and re-evaluate where at write time, returning only the rows that won the race. updateMany returns the array of written rows, an empty array means nothing matched at write time (e.g. you lost a concurrent claim), not an error. deleteMany's count is exactly the rows that still matched at delete time. deleteMany uses POST /:collection/delete-many (not DELETE) because it carries a body.
Versions and workflow
Available when the collection enables versioning (and workflow):
// findVersions({ id, limit?, offset? }), history rows tagged with version metadata.
const versions = await client.collections.posts.findVersions({ id, limit: 10 });
// each row: { ...row, versionId, versionNumber, versionOperation, versionUserId, versionCreatedAt }
// revertToVersion({ id, version? | versionId? }), restore an old version.
await client.collections.posts.revertToVersion({ id, version: 3 });
// transitionStage({ id, stage, scheduledAt? }), move workflow stage (no data mutation).
await client.collections.posts.transitionStage({ id, stage: "published" });A Date scheduledAt is serialized via .toISOString(); omit it entirely to transition now.
Uploads
upload / uploadMany exist only on collections that called .upload() server-side. They post multipart via XMLHttpRequest (not fetch) so you get progress and cancellation:
// upload(file, options?), multipart POST to /:collection/upload.
const asset = await client.collections.media.upload(file, {
onProgress: (pct) => setProgress(pct), // 0-100
signal: controller.signal, // abort to cancel
path: "uploads/2026", // optional destination folder
});
console.log(asset.url);
// uploadMany(files, options?), sequential; onProgress reports OVERALL batch progress.
const assets = await client.collections.media.uploadMany(files, {
onProgress: (overall, fileIndex) => setProgress(overall),
});Upload uses XHR, parses plain JSON, and throws `UploadError`
The upload path bypasses the fetch/SuperJSON pipeline: it sends credentials: true over XMLHttpRequest, and parses the response with plain JSON.parse (no SuperJSON). On failure it throws UploadError, not QuestpieClientError. UploadError carries status and response, with messages like "Upload failed", "Network error during upload", or "Upload cancelled". uploadMany aborts between files on signal and short-circuits to [] for an empty array.
Introspection
const meta = await client.collections.posts.meta(); // title field, timestamps, localized fields
const schema = await client.collections.posts.schema(); // fields, relations, access (for current user), validation JSON Schemameta() returns lightweight schema info for building dynamic UIs; schema() returns full introspection (including access evaluated for the current session), this is what the admin panel uses to auto-generate forms and tables.
Collection method reference
Every method takes an optional trailing options ({ locale?, localeFallback?, stage? }) that serializes into the query string, except the reads (which carry locale inside the query options) and upload/meta/schema.
| Method | HTTP | Returns |
|---|---|---|
find(options?) | GET /:collection | PaginatedResult (or GroupedPaginatedResult with groupBy) |
findOne(options?) | GET /:collection/:id (id-only where) or find w/ limit:1 | row or null |
count(options?) | GET /:collection/count | number |
create(data, options?) | POST /:collection | row |
updateById({ id, data }, options?) | PATCH /:collection/:id | row |
update(...) | none | alias of updateById |
deleteById({ id }, options?) | DELETE /:collection/:id | { success } |
delete(...) | none | alias of deleteById |
restoreById({ id }, options?) | POST /:collection/:id/restore | row (soft-delete only) |
updateMany({ where, data }, options?) | PATCH /:collection | row[] (claim-checked) |
updateBatch({ updates }, options?) | POST /:collection/update-batch | row[] |
deleteMany({ where }, options?) | POST /:collection/delete-many | { success, count } |
findVersions({ id, limit?, offset? }, options?) | GET /:collection/:id/versions | versioned row[] |
revertToVersion({ id, version?, versionId? }, options?) | POST /:collection/:id/revert | row |
transitionStage({ id, stage, scheduledAt? }, options?) | POST /:collection/:id/transition | row (workflow only) |
upload(file, options?) | POST /:collection/upload | created asset (.upload() only) |
uploadMany(files, options?) | POST /:collection/upload (sequential) | asset[] |
meta() | GET /:collection/meta | CollectionMeta |
schema() | GET /:collection/schema | CollectionSchema |
live(options, onSnapshot, opts?) | SSE | unsubscribe () => void |
liveIter(options?, opts?) | SSE | AsyncGenerator<snapshot> |
Globals
A global is a singleton, one row, no id. client.globals.<name> exposes get / update plus the same introspection, versioning, and workflow methods as collections.
// get(options?), read the singleton. Supports `with`, `columns`, `locale`, `localeFallback`, `stage`.
const settings = await client.globals.settings.get({ with: { logo: true } });
// update(data, options?), FIRST arg is the DATA object (no { id, data } wrapper).
const next = await client.globals.settings.update(
{ siteName: "QUESTPIE", logo: { connect: { id: assetId } } },
{ with: { logo: true } }, // options is the SECOND arg → query string
);`globals.update` takes the data object directly
Because a global is a singleton, update takes the data object as its first argument, update(data, options?), not the { id, data } shape that collections use. The second argument is the query options (with / locale / localeFallback / stage). Mixing them up (update({ id, data })) writes a bogus id field.
`get()` is typed non-null, but a global can be empty
The client types get() as non-null for ergonomics, while the underlying global CRUD can return null before the singleton has ever been written. In practice a global resolves to its default/empty record once initialized; guard for the unwritten case if you read a global before any update.
The remaining global methods mirror collections, with the singleton shapes:
| Method | HTTP | Notes |
|---|---|---|
get(options?) | GET /globals/:global | with / columns / locale / localeFallback / stage |
update(data, options?) | PATCH /globals/:global | data first, options second |
schema() / meta() | GET /globals/:global/schema · /meta | introspection |
findVersions(options?) | GET /globals/:global/versions | single options object (id?, limit?, offset?, locale opts) |
revertToVersion({ id?, version?, versionId? }, options?) | POST /globals/:global/revert | params in body, locale opts in query |
transitionStage({ stage, scheduledAt? }, options?) | POST /globals/:global/transition | no id (singleton) |
live(options, onSnapshot, opts?) | SSE | options are { with?, locale? } only |
liveIter(options?, opts?) | SSE | async generator form |
Custom routes
Your routes are reachable as a nested, callable proxy. A route file's path becomes a dot path, camelCased segments mapped to kebab-case URLs:
// routes/admin/stats.ts → client.routes.admin.stats(input)
const stats = await client.routes.admin.stats({ period: "week" });
// ^? typed from the route's input/output schema
// Calling at any depth sends POST {basePath}/admin/stats with a JSON body.For routes that declare an HTTP method (multi-export :GET, :POST, …), the leaf gains a method caller:
// routes/admin/stats.ts exporting `stats:GET` → client.routes.admin.stats.get(input)
const stats = await client.routes.admin.stats.get({ period: "week" });
// GET serializes `input` into the query string; POST/PUT/PATCH/DELETE send it as a JSON body.
// The full URL string is available without calling:
const url = client.routes.admin.stats.url; // "http://localhost:3000/api/admin/stats"The routes proxy is callable at any depth
client.routes is a Proxy, traversing deeper builds the URL path, and calling any node sends the request (default POST). Segment names are converted camelCase → kebab-case (adminStats → admin-stats). Method names are matched case-insensitively (.get, .post, .put, .delete, .patch). Untyped apps degrade to a permissive Record<string, any> so a typed client stays assignable to QuestpieClient<any>.
Search
client.search wraps a configured search adapter:
const results = await client.search.search({
query: "questpie", // required
collections: ["posts", "pages"], // optional, restrict scope
mode: "hybrid", // "lexical" | "semantic" | "hybrid"
limit: 20,
highlights: true,
facets: [{ field: "tags", limit: 10, sortBy: "count" }],
});
// results.docs: each is the full record + { _collection, _search: { score, highlights, indexedTitle } }
// results.total, results.facets?
// Force a rebuild of a collection's index.
await client.search.reindex("posts");SearchResponse is { docs: PopulatedSearchResult[]; total: number; facets? }, where each doc is T & { _collection, _search }. reindex returns { success, collection }; its server-side access defaults to the target collection's update rule.
Realtime, live queries
live() and liveIter() subscribe to access-controlled query snapshots pushed over a single multiplexed SSE connection (POST /realtime). The first snapshot arrives immediately on connect; later snapshots whenever a matching record changes. The snapshot type equals the find() / get() result type for the same options.
// live(options, onSnapshot, opts?), callback form. Returns an unsubscribe function.
const unsubscribe = client.collections.posts.live(
{ where: { published: true }, orderBy: { createdAt: "desc" }, limit: 20 },
(snapshot) => {
// snapshot is the SAME shape as find() → { docs, totalDocs, ... }
setPosts(snapshot.docs);
},
{
signal: controller.signal, // optional, unsubscribes on abort
onError: (err) => console.error("realtime dropped", err), // optional, SSE failure
},
);
// Later:
unsubscribe();// liveIter(options?, opts?), async-generator form, for workers / agents / tests.
for await (const snapshot of client.collections.posts.liveIter(
{ where: { published: true } },
{ signal: controller.signal }, // only `signal`, no onError
)) {
console.log(snapshot.totalDocs);
}Globals expose the same two methods, with { with?, locale? } options only:
const unsubscribe = client.globals.settings.live({}, (snapshot) => {
setSettings(snapshot);
});`live()` carries a SUBSET of query options
Live queries only carry where, with, limit, offset, orderBy, and locale on the wire (LiveQueryOptions), they deliberately exclude columns, groupBy, search, includeDeleted, and stage, because each snapshot is a full query result re-run server-side from those fields. Globals carry with + locale only. Pass an unsupported option and it is dropped, not applied.
Always provide an unsubscribe path, and handle `onError`
The callback form returns an unsubscribe function; call it (or pass opts.signal and abort) when the component unmounts, or the SSE subscription leaks. The onError callback fires when the connection fails, wire it so a dropped stream surfaces instead of an infinite loading state. liveIter() only accepts signal (terminate by aborting it); it has no onError. Changing setLocale() does not re-key already-open subscriptions, locale is carried per-topic at subscribe time.
Low-level realtime API
live() / liveIter() are thin wrappers over client.realtime plus a topic builder. Prefer the typed wrappers, they infer the snapshot type and build the topic for you. Reach for the raw API only when you need a hand-built topic:
import { buildCollectionTopic, buildGlobalTopic } from "questpie/client";
const topic = buildCollectionTopic("posts", { where: { published: true }, limit: 20 });
// subscribe<TData>(topic, callback, signal?, customId?, onError?) → unsubscribe
const unsubscribe = client.realtime.subscribe(topic, (data) => {
// `data` is untyped here (TData defaults to unknown), the wrappers add the types
});
// stream<TData>(topic, signal?, customId?) → AsyncGenerator
for await (const snap of client.realtime.stream(topic)) { /* ... */ }
client.realtime.destroy(); // tear down the multiplexer (re-creates lazily on next use)buildCollectionTopic(name, options?) and buildGlobalTopic(name, options?) return a TopicConfig ({ resourceType, resource, ...defined options }), undefined option keys are omitted so the topic hash is stable. Both are value exports from questpie/client. client.realtime exposes subscribe, stream, destroy, and the topicCount / subscriberCount getters (which read 0 before the multiplexer is created).
One SSE connection for every topic
The realtime API lazily creates a single RealtimeMultiplexer on the first subscribe/stream, and runs all topics over one POST /realtime connection, this sidesteps the browser's HTTP/1.1 six-connections-per-domain limit. The connect body is plain JSON (not SuperJSON); reconnects are debounced with exponential backoff.
Localization
The client tracks one "current" locale and exposes helpers to read and change it:
client.setLocale("de"); // all subsequent requests send accept-language: de
client.getLocale(); // "de"
client.getBasePath(); // "/api" ("" when basePath is "/")
client.setLocale(); // pass nothing to clear the locale headerPer-call locale overrides go through the method options instead, e.g. find({ where, locale: "de" }) or create(data, { locale: "de" }), which serialize into the query string and take precedence over the header for that one call.
`setLocale` mutates shared headers; it does not touch open SSE streams
setLocale(locale) rewrites the client's shared default headers in place, so it affects every subsequent collection / global / route / search request. It does not re-key already-open realtime subscriptions, those carry their locale per-topic from when they were opened. Re-subscribe (or pass locale in the live options) to change a live query's locale.
Error handling
Every fetch-based method (find, create, update, …, search, route calls) throws QuestpieClientError on a non-2xx response. It parses the server's { error: ApiErrorShape } envelope (SuperJSON or JSON) and exposes typed helpers:
import { QuestpieClientError } from "questpie/client";
try {
await client.collections.posts.create({ title: "" /* missing slug */ });
} catch (err) {
if (err instanceof QuestpieClientError) {
err.status; // 400
if (err.isCode("VALIDATION_ERROR")) {
err.getFieldErrorsMap(); // { slug: ["Required"] }
err.getFieldError("slug"); // { path: "slug", message: "Required" }
}
}
}QuestpieClientError carries status, statusText, url, and, when the body is an ApiErrorShape, code, fieldErrors, and context. Uploads are the exception: they throw UploadError (see Uploads).
Framework adapters
The client talks to a server handler that your host framework mounts. The handler is framework-agnostic (createFetchHandler); each framework gets a one-line adapter. The adapter's basePath must match the client's basePath.
Next.js, @questpie/next
import { questpieNextRouteHandlers } from "@questpie/next";
import { app } from "#questpie";
// All verbs share one handler.
export const { GET, POST, PATCH, DELETE, PUT, OPTIONS, HEAD } =
questpieNextRouteHandlers(app, { basePath: "/api" });questpieNextRouteHandlers(app, config?) returns { GET, POST, PATCH, DELETE, PUT, OPTIONS, HEAD } all bound to the same handler, the idiomatic App Router catch-all. questpieNext(app, config?) is the single-handler form (returns a (request) => Promise<Response>), converting an out-of-base request into a 404 JSON. @questpie/next has a single . export (no /server subpath).
Hono, @questpie/hono/server
import { Hono } from "hono";
import { questpieHono } from "@questpie/hono/server";
import { app } from "#questpie";
const server = new Hono();
server.route("/", questpieHono(app, { basePath: "/api" }));
export default server;questpieHono(app, config?) returns a Hono app mounting a catch-all ${basePath}/*; mount it with server.route("/", …). It forces accessMode: "user" and reads context from c.get("appContext") / c.get("user") when present. Add questpieMiddleware(app) if you want QUESTPIE's context vars (app, appContext, user) on your own Hono routes. For custom routes fully typed via Hono RPC, createClientFromHono merges a QUESTPIE client with an hc client.
Elysia, @questpie/elysia/server
import { Elysia } from "elysia";
import { questpieElysia } from "@questpie/elysia/server";
import { app } from "#questpie";
const server = new Elysia()
.use(questpieElysia(app, { basePath: "/api" }));
export default server;questpieElysia(app, config?) returns an Elysia plugin (.use(...)) with a catch-all /* under prefix=basePath, forcing accessMode: "user" and returning 404 JSON when out of base. For custom routes fully typed via Eden Treaty, createClientFromEden merges a QUESTPIE client with an Eden client.
One client, swappable backend
createClient<AppConfig> is the same regardless of which adapter serves the API, only the server mount changes. Keep baseURL + basePath aligned with the adapter and every collection/global/route call works unchanged across Next, Hono, and Elysia. The Hono and Elysia packages also ship framework-native client helpers (createClientFromHono, createClientFromEden) for typing custom routes through Hono RPC / Eden Treaty while keeping QUESTPIE's CRUD surface.
TypeScript
createClient<AppConfig> is fully inferred from your generated AppConfig, so most code needs no extra type imports. When you do want the shapes, the public ones come from questpie/client:
import {
createClient,
QuestpieClientError,
UploadError,
type QuestpieClientConfig,
type QuestpieClient,
type SearchOptions,
type SearchResponse,
type LiveQueryOptions,
type LiveSubscribeOptions,
// result + query types (shared with the server), for tooling like TanStack Query:
type Where,
type With,
type OrderBy,
type FindManyOptions,
type ApplyQuery,
type FindResult,
type PaginatedResult,
// realtime value + type exports:
buildCollectionTopic,
buildGlobalTopic,
type RealtimeAPI,
type TopicConfig,
} from "questpie/client";To derive a specific call's argument or result type, index into your client value:
import { client } from "@/lib/client.js";
type PostsFindResult = Awaited<ReturnType<typeof client.collections.posts.find>>;
type Post = PostsFindResult["docs"][number];
type AppClient = typeof client; // hand the whole client to helpersQuestpieClientConfig, QuestpieClient, QuestpieClientError, UploadError, UploadOptions, the search types, the live/topic types, and the shared query types (Where, With, FindManyOptions, ApplyQuery, FindResult, …) are all exported from questpie/client. The createClient/Where/With/FindResult re-exports are exactly what @questpie/tanstack-query builds its hooks on.
Related
- Getting started, scaffolds
src/lib/client.tsand wires the adapter for you. - TanStack Query,
queryOptions/mutationOptionsbuilders over this client, with realtime-backed live queries. - Collections, the server definition behind
client.collections.*(CRUD, query language, uploads). - Globals, the singleton counterpart behind
client.globals.*. - Routes, defining the custom endpoints
client.routes.*calls. - Runnable example:
examples/tanstack-barbershop, a fullstack app using the typed client end to end.
Overview
Use your generated AppConfig from the browser or another service with the typed client, TanStack Query helpers, and realtime subscriptions.
TanStack Query
One factory turns your typed client into ready-made queryOptions() and mutationOptions() for every collection, global, and route, fully typed, with stable keys, error mapping, and opt-in realtime, that you pass straight into useQuery and useMutation.