QUESTPIE
Client

TanStack Query

One factory turns the typed client into finished queryOptions() and mutationOptions() objects, so a React component reads a collection in one line and never invents a cache key.

View markdown

You have a typed client and a React app. This page is where the two meet: which builder to call, what it hands back, and what lands in the cache.

Install

Every starter already depends on it. To add it to an app of your own:

bun add @questpie/tanstack-query @tanstack/react-query

The peers are @tanstack/react-query v5 and questpie. It is ESM only.

Build the proxy once

createQuestpieQueryOptions(client) returns an object of builder proxies. Create it at module scope. The starters put it in this file for you.

src/lib/query.ts
import { createQuestpieQueryOptions } from "@questpie/tanstack-query";

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

export const q = createQuestpieQueryOptions(client);

One component

A builder returns the whole options object. Pass it straight to the hook.

src/components/posts.tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

import { q } from "@/lib/query";

export function Posts() {
	const queryClient = useQueryClient();
	const { data } = useQuery(
		q.collections.posts.find({ where: { published: true }, limit: 10 }),
	);

	const create = useMutation({
		...q.collections.posts.create(),
		onSuccess: () =>
			queryClient.invalidateQueries({
				queryKey: q.key(["collections", "posts"]),
			}),
	});

	return (
		<>
			<ul>
				{data?.docs.map((post) => (
					<li key={post.id}>{post.title}</li>
				))}
			</ul>
			<button onClick={() => create.mutate({ title: "Hello", slug: "hello" })}>
				Add post
			</button>
		</>
	);
}

That renders up to ten published posts, typed from your schema. The button writes a row and invalidates every posts query, so the list refetches itself. You wrote no hook and no key.

What hangs off q

BranchWhat it builds
q.collections.<name>Reads and writes for one collection
q.globals.<name>Reads and writes for one global
q.channels.<name>Message and presence streams
q.routes.<path>.<method>query(), mutation() and key()
q.customThe same wrapping around a function of yours
q.key(parts)A key with your prefix already on the front

Names are checked against your generated AppConfig. q.collections.newz is a compile error, and so is a relation or column typo inside find().

Reads

q.collections.posts.find({
	where: { published: true },
	with: { author: true },
});
q.collections.posts.count({ where: { published: true } });
q.collections.posts.findOne({ where: { slug: "hello" } });
q.collections.posts.findVersions({ id: postId, limit: 20 });
BuilderdataOption set
find(options?, live?)the paginated envelopethe full find() set
count(options?, live?)numberwhere and includeDeleted
findOne(options?)the row, or nullthe find() set minus paging
findVersions({ id, limit?, offset? })the version rowsid is required

`find()` hands back a page, `findOne()` hands back `null`

Read your rows off data.docs, never off data. findOne() resolves to null when nothing matches, so guard it.

find, count and the global get take a second argument, and that is where live data lives.

Writes

A mutation builder takes no arguments. It hands back mutationOptions(), and the variables go to mutate().

const update = useMutation(q.collections.posts.update());
update.mutate({ id: postId, data: { published: true } });
Buildermutate(variables)Resolves to
create()the create inputthe new row
update(){ id, data }the updated row
delete(){ id }{ success, data }
updateMany(){ where, data }the rows it wrote

update() and delete() work by id, never by filter. The rest of the writes live on Mutations.

Globals

A global is a singleton, so get() resolves to the row and never to null.

const settings = useQuery(q.globals.siteSettings.get({ with: { logo: true } }));

const save = useMutation(q.globals.siteSettings.update());
save.mutate({ data: { siteName: "QUESTPIE" } });

Global writes nest their payload

update() takes {data}, not the fields directly. revertToVersion() and transitionStage() take {params}. The collection builders take the same things unwrapped.

Routes

The path mirrors your route file and ends in the HTTP method, lowercased. Same traversal as client.routes.

const stats = useQuery(
	q.routes.dashboard.getStats.post.query({ period: "week" }),
);

const send = useMutation(q.routes.notifications.send.post.mutation());
send.mutate({ to: "user@example.com" });

.query(input?) builds a query and .mutation() builds a mutation. .key(input?) rebuilds the query key, for invalidation or prefetch.

Channels

const { data: messages = [] } = useQuery(
	q.channels.chatRoom.subscription({ roomId }),
);
const { data: members = [] } = useQuery(
	q.channels.chatRoom.presence({ roomId }),
);

subscription() appends each typed message to an array. presence() replaces its value with the newest roster. Both close when the query aborts. A channel whose wire pattern declares params requires them, and presence() appears only on channels that declare presence.

Where each topic lives

TopicPage
Every write builder and its variablesMutations
{ realtime: true } on find/count/getLive queries
Key shapes and how to invalidateQuery keys
keyPrefix, errorMap, locale, stageConfiguration
The client every builder wrapsSDK
Channel definitions and authorizationChannels
Keeping a live UI cheapReactive apps
Date values through dehydrationTemporal values

Next

Mutations is the rest of the writes: bulk, versions, workflow stages and soft delete.

On this page