QUESTPIE
Client

Client SDK

One typed object that calls your app over HTTP. Collections, globals, routes, search, channels and realtime, with every argument and return shape inferred from the AppConfig codegen wrote.

View markdown

A wrong collection name is a compile error here. So is a wrong where field, and so is a key on create that the collection does not have. The server still checks access and validates the value it receives.

What is on the client

createClient<AppConfig>(config) returns the members below. It also returns crdt, which throws the moment you read it.

MemberWhat it is
collectionsTyped CRUD, one object per collection
globalsget and update for each singleton
routesYour own endpoints, reached through a nested proxy
searchsearch() and reindex() against the search adapter
realtimeThe subscription API that live() is built on
channelsTyped application event streams
setLocaleSets the accept-language header on later requests
getLocaleReads that locale back
getBasePathReads the normalized base path back

`client.crdt` throws when you read it

It was removed so 164 KB of CRDT code stays out of bundles that never open a document. Build it from the client you already have with createCrdtClient(client), imported from questpie/crdt. See Collaborative documents.

Create it

Create the client once and export it. createClient comes from questpie/client. AppConfig is the type codegen wrote, and it is what makes every call typed.

src/lib/client.ts
import { createClient } from "questpie/client";

import type { AppConfig } from "#questpie";

export const client = createClient<AppConfig>({
	baseURL:
		typeof window !== "undefined"
			? window.location.origin
			: process.env.APP_URL || "http://localhost:3000",
	basePath: "/api",
});

Then call it from anywhere. The same client runs in a browser, a Bun script, a worker or a test.

import { client } from "@/lib/client.js";

const { docs, totalDocs } = await client.collections.posts.find({
	where: { published: true },
	limit: 10,
});

AppConfig is a type parameter, not a value. Run questpie generate after a schema change and every method moves with it. TypeScript checks the names, and the server still checks access and validates the input.

Configuration

OptionDefaultWhat it does
baseURL (required)noneOrigin the client calls. Joined with basePath to build every URL.
basePath"/"Where the server handler is mounted. It has to match.
fetchglobalThis.fetchYour own fetch. An instrumented one, or a polyfill.
headers{}Headers merged into every request.
getAuthHeadersnoneRuns before each request and returns headers. Beats headers on a conflict.
useSuperJSONtrueSends and parses SuperJSON, so Date, Map, Set and BigInt survive.
crdtnoneRuntime config for collaborative documents.

Every request sends credentials: "include", so a session cookie travels with it. Reach for getAuthHeaders when the token rotates, such as a bearer token in a mobile app. It runs fresh on each request, so you never rebuild the client. basePath gains a leading slash if you left it off, and loses a trailing one.

`basePath` must match the server, and `/` reads back as `''`

The client's basePath and the handler's basePath have to be identical or every request 404s. Use "/api" for an app that also serves a frontend. getBasePath() returns the normalized value, which is "" when you passed "/".

Collection methods

client.collections.<name> carries the methods below. Reads take the full query language. Mutations take a second argument of locale, localeFallback and stage, which goes into the query string. The uploads and the two introspection calls are the exceptions.

MethodHTTPReturns
find(options?)GET /:collectiona page of rows
findOne(options?)GET /:collection/:id, else find at limit 1row or null
count(options?)GET /:collection/countnumber
create(data, options?)POST /:collectionrow
updateById({ id, data }, options?)PATCH /:collection/:idrow
deleteById({ id }, options?)DELETE /:collection/:id{ success, data }
restoreById({ id }, options?)POST /:collection/:id/restorerow
purgeById({ id }, options?)POST /:collection/:id/purge{ success: true }
updateMany({ where, data }, options?)PATCH /:collectionrow[], claim-checked
updateBatch({ updates }, options?)POST /:collection/update-batchrow[]
deleteMany({ where }, options?)POST /:collection/delete-many{ success, count }
findVersions({ id, limit?, offset? }, options?)GET /:collection/:id/versionsrows plus version metadata
revertToVersion({ id, version?, versionId? }, options?)POST /:collection/:id/revertrow
transitionStage({ id, stage, scheduledAt? }, options?)POST /:collection/:id/transitionrow
upload(file, options?)POST /:collection/uploadcreated row
uploadMany(files, options?)POST /:collection/upload, one file at a timerow[]
meta()GET /:collection/metaCollectionMeta
schema()GET /:collection/schemaCollectionSchema
live(options, onSnapshot, opts?)realtimean unsubscribe function
liveIter(options?, opts?)realtimeAsyncGenerator of the same snapshots

update, delete and restore are aliases of the three …ById methods, so on the client they act on one row. The server gives those same names to its bulk operations, so prefer the …ById names. purgeById appears only on collections with soft delete on. Every collection types upload, but the server answers 400 unless the collection called .upload().

Global methods

A global is one row with no id, so client.globals.<name> is a shorter list.

MethodHTTPNotes
get(options?)GET /globals/:globalwith, columns, locale and stage
update(data, options?)PATCH /globals/:globaldata first, query options second
meta() and schema()GET /globals/:global/meta, /schemaintrospection
findVersions(options?)GET /globals/:global/versionsone options object, no separate params
revertToVersion({ id?, version?, versionId? }, options?)POST /globals/:global/revertparams in the body
transitionStage({ stage, scheduledAt? }, options?)POST /globals/:global/transitionno id
live(options, onSnapshot, opts?)realtimeoptions are with and locale only
liveIter(options?, opts?)realtimethe generator form

update takes the data object directly, not the { id, data } wrapper that collections use.

Your routes arrive as a nested proxy. Each folder is one dot, and the HTTP method is the leaf that sends the request. client.search holds two methods and talks to your search adapter.

// routes/admin/stats.post.ts
const stats = await client.routes.admin.stats.post({ period: "week" });

const results = await client.search.search({ query: "questpie", limit: 20 });
await client.search.reindex("posts");

Route leaves are get, post, put, patch and delete. A get turns its input into the query string. Any other property is one more path segment, camelCase to kebab-case, so adminStats requests admin-stats. Search hands back { docs, total, facets? }, each doc carrying _collection and _search.

Locale

setLocale rewrites the client's shared headers in place, so it moves every later request on that client. It does not reach a subscription that is already open, because live() builds its topic when you call it. Pass locale in the live options instead.

client.setLocale?.("de"); // sends accept-language: de
client.getLocale?.(); // "de"
client.setLocale?.(); // pass nothing to clear it

All three are typed optional on QuestpieClient, so call them with ?.(). Per-call overrides go in the method options instead, such as find({ locale: "de" }). See Client i18n.

Where each topic lives

TopicPage
Every collection method, with examplesCollection methods
Sending files, progress and cancellationUploads
Every global methodGlobal methods
What is thrown, and how to read a field errorErrors
Mounting the handler the client callsFramework adapters
live(), liveIter() and the raw subscribe APIRealtime
Typed events, presence and publishingChannels
React hooks over this clientTanStack Query
Which search adapter answers client.searchSearch
revision and expectedRevisionOptimistic concurrency

Next

Collection methods is the same list again, with a runnable call for each one.

On this page