QUESTPIE
Client

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.

@questpie/tanstack-query wraps your typed client in a single factory, createQuestpieQueryOptions(client), that returns a proxy of option builders. Each builder hands you a finished queryOptions() or mutationOptions() object, query key, fetcher, and error mapping already assembled, so a React component is one line: useQuery(q.collections.posts.find({ limit: 10 })). The types flow from your schema through the client into the hook, so collection names, where filters, and result shapes are all inferred. No hooks to write, no keys to invent.

What it does

  • One builder per operation. q.collections.<name>.find(), .create(), .update(); q.globals.<name>.get(), .update(); q.routes.<path>.query(), .mutation(), each returns a ready queryOptions() / mutationOptions() object for useQuery / useMutation.
  • Typed end to end. Every builder is derived from the client's own method signatures, so a find() here narrows its result exactly like a direct client.collections.posts.find() call. Variables, options, and return types are all inferred from your schema.
  • Stable, structured query keys. Keys are built and sanitized for you (['questpie', 'collections', 'posts', 'find', locale, stage, options]), so React Query's cache, refetch, and invalidation work without you hand-rolling keys.
  • Opt-in realtime. Pass { realtime: true } to find, count, or get and, if your client has a realtime adapter, the query becomes a live SSE-backed stream instead of a one-shot fetch. Same builder, same hook.
  • One error shape. Every fetcher and mutator is wrapped with an errorMap, so thrown non-Error values are coerced to Error before they reach React Query (override it once, globally).
  • An escape hatch. q.custom.query / q.custom.mutation wrap any async function with the same key-prefixing and error mapping, for anything the proxies don't cover.

Install

The package ships with the TanStack Start template already. To add it to an existing app, install it alongside its peers:

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

Peer dependencies are @tanstack/react-query ^5.0.0 and questpie ^3.0.0, this package builds on React Query v5 and your QUESTPIE client. It's ESM-only.

Quick start

Create the option-builder proxy once, from your typed client, and export it. The scaffold does this for you in src/lib/query.ts:

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

import { client } from "@/lib/client.js"; // createClient<AppConfig>()

// `q.collections.*`, `q.globals.*`, `q.routes.*` are typed from your schema.
export const q = createQuestpieQueryOptions(client);

Now use it in any component. A builder returns the full options object, pass it straight into the hook:

A component
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

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

function Posts() {
  const queryClient = useQueryClient();

  // Read: q.collections.posts.find(...) returns queryOptions()
  const { data } = useQuery(
    q.collections.posts.find({ where: { published: true }, limit: 10 }),
  );

  // Write: q.collections.posts.create() returns mutationOptions()
  const create = useMutation({
    ...q.collections.posts.create(),
    onSuccess: () => {
      // Invalidate every posts query with the top-level key helper.
      queryClient.invalidateQueries({ queryKey: q.key(["collections", "posts"]) });
    },
  });

  return (
    <div>
      <p>{data?.totalDocs ?? 0} published posts</p>
      <button
        onClick={() => create.mutate({ title: "Hello", slug: "hello", published: true })}
      >
        Add post
      </button>
    </div>
  );
}

find() resolves to the same paginated envelope the client returns, { docs, totalDocs, page, hasNextPage, … }, so read your rows off data.docs. The mutation variables ({ title, slug, published }) are the collection's create input, inferred from your schema.

Build the proxy once, not per render

createQuestpieQueryOptions(client) is cheap, but the returned proxies are stable identities, create it once at module scope (as the scaffold does) and import q everywhere. The builders themselves are pure: calling q.collections.posts.find(opts) only builds an options object; nothing fetches until you hand it to useQuery.

Example → inferred types

Each builder mirrors the client method it wraps, so the inferred result is identical to a direct client call, not a widened {}:

const opts = q.collections.posts.find({ where: { published: true }, limit: 10 });
//    ^? UseQueryOptions<FindResult<…, { docs: Post[]; totalDocs: number; … }>>

const createOpts = q.collections.posts.create();
//    ^? UseMutationOptions<Post, DefaultError, { title: string; slug: string; published?: boolean }>

const settings = q.globals.siteSettings.get();
//    ^? UseQueryOptions<{ id: string; siteName: string; … }>  (non-nullable, globals are singletons)

The selection generic is applied per call, find<TQuery>(options?) narrows FindResult from the exact options you pass, the same way client.collections.posts.find(...) does.

Collections

q.collections.<name> exposes one builder per CRUD operation. Queries (reads) return queryOptions(); mutations (writes) return mutationOptions() whose variables are the operation's input.

Queries

// List, paginated envelope. Optional second arg enables realtime (below).
q.collections.posts.find({
  where: { published: true },
  with: { author: true },
  orderBy: { createdAt: "desc" },
  limit: 10,
});

// Count, number (or live total with { realtime: true }).
q.collections.posts.count({ where: { published: true } });

// Single row or null.
q.collections.posts.findOne({ where: { slug: "hello" } });

// Version history (versioning collections). `id` is required.
q.collections.posts.findVersions({ id: postId, limit: 20 });
BuilderReturns (data)OptionsRealtime
find(options?, queryConfig?)FindResult, { docs, totalDocs, … }where, with, orderBy, limit, offset, columns, locale, stage, localeFallback, includeDeletedyes
count(options?, queryConfig?)numberwhere, includeDeletedyes
findOne(options?)row `null`same as find (single result)
findVersions(params)Array<row & { versionId, versionNumber, … }>{ id, limit?, offset? }, id requiredno

`find()` returns an envelope; `findOne()` can be `null`

find() always resolves to { docs, totalDocs, totalPages, page, hasNextPage, … }, your rows are on data.docs, never data directly. findOne() resolves to the row or null when nothing matches, so guard it (data ?? fallback). Only find and count take the realtime queryConfig second argument, findOne does not (passing { realtime: true } to it is a type error).

Mutations

Each mutation builder takes no arguments and returns mutationOptions(); you call mutate(variables) with the shape below. Variables are inferred from the underlying client method.

const create = useMutation(q.collections.posts.create());
create.mutate({ title: "Hello", slug: "hello", published: true }); // the create data

const update = useMutation(q.collections.posts.update());
update.mutate({ id: postId, data: { published: true } }); // { id, data }

const remove = useMutation(q.collections.posts.delete());
remove.mutate({ id: postId }); // { id } -> { success: boolean }
Buildermutate(variables) shapeReturns (data)
create()the create data objectthe created row
update(){ id, data }the updated row
delete(){ id }{ success: boolean }
restore(){ id }the restored row (soft-delete collections)
revertToVersion(){ id, version } or { id, versionId }the reverted row (versioning)
transitionStage(){ id, stage }the row at its new stage (workflow)
updateMany(){ where, data }the written rows
deleteMany(){ where }{ success: boolean; count: number }

`update`/`delete` are by id; use `updateMany`/`deleteMany` for filters

update() and delete() wrap the single-record by-id client methods, their variables are { id, data } and { id }, never a where clause. For bulk operations use updateMany({ where, data }) / deleteMany({ where }). Two caveats on the bulk pair: their where/data variables are typed any (no schema-level checking of the filter, unlike find's typed where), and updateMany's declared return type is a single row while the runtime actually returns an array of written rows, the static type is narrower than reality.

Globals

A global is a singleton, so q.globals.<name> exposes only get, update, findVersions, revertToVersion, and transitionStage, there is no find / findOne / count / create / delete.

// Read the singleton. Non-nullable result. { realtime: true } supported.
q.globals.siteSettings.get({ with: { logo: true } });

// Update, note the NESTED `data`.
const update = useMutation(q.globals.siteSettings.update());
update.mutate({ data: { siteName: "QUESTPIE" } });
Buildermutate(variables) / optionsReturnsRealtime
get(options?, queryConfig?){ with?, columns?, locale?, localeFallback?, stage? }the singleton row (non-nullable)yes
update(){ data, options? }the updated globalnone
findVersions(options?){ id?, limit?, offset?, locale?, localeFallback? }version arraynone
revertToVersion(){ params, options? }the reverted globalnone
transitionStage(){ params, options? }the global at its new stagenone

Global mutations nest their payload under `data` / `params`

This is the one spot that trips people up. q.globals.siteSettings.update().mutate(...) takes { data: { … } }, not the fields directly, mutate({ data: { siteName: "X" } }), never mutate({ siteName: "X" }). Likewise revertToVersion and transitionStage take { params, options? } (params nested), whereas the collection equivalents take the params object directly.

`get()` is non-nullable, unlike `collections.findOne()`

A global always exists (it's a singleton with defaults), so q.globals.<name>.get() resolves to the row, not row | null. You don't guard it the way you guard findOne.

Routes

q.routes mirrors your route tree. Traverse the dot-path to a route function, then call .query(input?), .mutation(), or .key(input?):

// Read a route as a query.
const stats = useQuery(q.routes.dashboard.getStats.query({ period: "week" }));

// Call a route as a mutation.
const send = useMutation(q.routes.notifications.send.mutation());
send.mutate({ to: "user@example.com", subject: "Hi" });

// Build the route's query key (for invalidation / prefetch).
const key = q.routes.dashboard.getStats.key({ period: "week" });
  • .query(input?)queryOptions(). The input is the route's first argument, typed from your route signature.
  • .mutation()mutationOptions(). mutate(variables) calls the route with variables; if the route takes no arguments, mutate() calls it with none.
  • .key(input?) → the route's query key, for invalidateQueries / prefetch.

`.key()` always builds the query key, there is no mutation-key helper

q.routes.<path>.key() returns the same key shape as .query() (the 'query' segment), even when a sibling .mutation() exists. Mutation keys are set internally on the mutation options object; there's no helper to construct one. Use .key() to invalidate or prefetch a route query.

Unknown routes throw at fetch time, not build time

The routes proxy is loose, q.routes.a.b.c.query() builds an options object for any dot-path. The path is only resolved against client.routes when the query runs; if it doesn't resolve to a function the fetcher throws Route not found at path: a.b.c. There's no build-time check that the route exists, so a typo'd path surfaces as a runtime query error, not a red squiggle. The same lazy-resolution applies to unknown collection/global names.

Realtime

Three reads can become live by passing { realtime: true } as their second argument: collections.<name>.find, collections.<name>.count, and globals.<name>.get. The query fetches once for initial data, then the server pushes a full snapshot on every matching change over the multiplexed SSE stream.

function LivePosts() {
  // Re-renders whenever a matching post changes, no manual invalidation.
  const { data } = useQuery(
    q.collections.posts.find({ where: { published: true } }, { realtime: true }),
  );
  return <p>{data?.totalDocs ?? 0} published posts (live)</p>;
}

Under the hood a live query uses React Query's experimental_streamedQuery with refetchMode: "append"; for count, the reducer reads totalDocs off each snapshot so data stays a number.

Realtime needs a wired client, otherwise the flag is silently ignored

Live mode activates only when both hold: queryConfig.realtime is truthy and your client has a realtime adapter (client.realtime is present). If the client has no realtime adapter configured, { realtime: true } is silently ignored and the query falls back to a one-shot fetch, no error, no warning. findOne, findVersions, and all mutations never stream.

Realtime topic ignores some read options

The live subscription's topic is derived from your read options via buildCollectionTopic / buildGlobalTopic, which carry only where / with / limit / offset / orderBy / locale (globals: where / with / locale). columns, stage, includeDeleted, and localeFallback are dropped from the topic, and the config-level locale/stage are not added to it, so live matching ignores those. Both builders are re-exported from the package if you want to subscribe manually via client.realtime.subscribe.

Configuration

createQuestpieQueryOptions(client, config?) takes an optional second argument. Every field has a default; config defaults to {}.

export const q = createQuestpieQueryOptions(client, {
  keyPrefix: ["questpie"],   // QueryKey prepended to every key. Default ['questpie'].
  errorMap: defaultErrorMap, // (error: unknown) => unknown. Wraps every fetcher/mutator.
  locale: undefined,         // string, baked into keys AND threaded into every call.
  stage: undefined,          // string, same: keyed and threaded.
});
OptionTypeDefaultEffect
keyPrefixQueryKey['questpie']Prepended to every query/mutation key. Pass [] to emit keys with no prefix.
errorMap(error: unknown) => unknowndefaultErrorMapRuns in the catch of every fetcher/mutator; its return value is thrown instead of the raw error.
localestringundefinedPinned read/write locale, see the warning below.
stagestringundefinedPinned workflow stage, see the warning below.

`locale` and `stage` are global to the proxy and override per-call options for globals

A locale/stage set in config is baked into every query key and threaded into every read and mutation, for collections as the call's options/LocaleOptions second argument, for globals merged into the options object. Because they're spread after the caller's options, they override any per-call locale/stage on globals.update / revertToVersion / findVersions. If you need different locales in different parts of the UI, create a second proxy with its own config rather than fighting the override. (One asymmetry: globals.transitionStage merges only locale from config, not stage.

Custom error mapping

The default errorMap coerces non-Error throws into Error (passes Error through, wraps a string as new Error(string), else new Error("Unknown error")). Override it once to normalize errors app-wide:

export const q = createQuestpieQueryOptions(client, {
  errorMap: (error) => {
    if (error instanceof MyApiError) return error;
    return new Error("Request failed", { cause: error });
  },
});

The map wraps every queryFn and mutationFn, including the initial fetch of a realtime stream, so whatever it returns is what your onError / error boundary receives.

Query keys & invalidation

Keys are built and sanitized for you. The runtime shape always includes the locale and stage slots (even when undefined), so a partial-match prefix is the safe way to invalidate.

Collections (reads):  [...keyPrefix, 'collections', <name>, 'find'|'findOne'|'count'|'findVersions', locale, stage, options]
Collections (writes): [...keyPrefix, 'collections', <name>, 'create'|'update'|…, locale, stage]
Globals (reads):      [...keyPrefix, 'globals', <name>, 'get'|'findVersions', locale, stage, options]
Routes:               [...keyPrefix, 'routes', ...segments, 'query'|'mutation', locale, (input for query)]

Mutation keys never include the variables. Route keys carry locale but not stage (unlike collections/globals, which carry both).

Build keys for invalidation with the top-level q.key(parts) helper, it prepends your keyPrefix:

// Invalidate every posts query (partial-match, all ops, all options).
queryClient.invalidateQueries({ queryKey: q.key(["collections", "posts"]) });

// Invalidate one route query exactly.
queryClient.invalidateQueries({ queryKey: q.routes.dashboard.getStats.key({ period: "week" }) });

// Or read the key straight off any builder result:
const { queryKey } = q.collections.posts.find({ where: { published: true } });
queryClient.invalidateQueries({ queryKey });

There is no `.key()` on `q.collections.<name>` or `q.globals.<name>`

Only routes have a per-leaf .key() builder. Collections and globals do not expose a key method, to invalidate them, use the top-level q.key([...]) for partial matching, or read .queryKey off the options object a builder returns. For an exact key match you'd also have to replicate the […, locale, stage, options] tail yourself, so partial-match invalidation via q.key(["collections", "posts"]) is almost always what you want.

Functions in options are stripped from the key

Key options are sanitized: function-valued properties are removed and undefined-valued keys are dropped (null is kept) before they land in the query key, so non-serializable values don't break React Query's structural key equality. The footgun: two find() calls that differ only by a function argument produce the same key and therefore share a cache entry. Don't encode a discriminator as a function in your options.

Custom queries & mutations

For anything the proxies don't cover, a third-party endpoint, a composed call, a hand-rolled fetch, q.custom wraps your async function with the same keyPrefix and errorMap:

// Query
const opts = q.custom.query<DashboardData>({
  key: ["dashboard", "summary"], // suffix only, keyPrefix is prepended for you
  queryFn: () => fetchDashboard(),
});

// Mutation
const mut = q.custom.mutation<{ id: string }, void>({
  key: ["dashboard", "refresh"],
  mutationFn: (vars) => refreshDashboard(vars.id),
});

You pass the key suffix; the factory prepends your keyPrefix (default ['questpie']). The function is wrapped with errorMap like every other builder. No locale/stage interleaving happens for custom keys, you control the full suffix.

TypeScript

The package re-exports the React Query types you'll reference, plus its own:

import type {
  // Re-exported from @tanstack/react-query for convenience:
  QueryKey,
  DefaultError,
  UseQueryOptions,
  UseMutationOptions,
  // This package's types:
  QuestpieQueryOptionsProxy,  // return type of createQuestpieQueryOptions
  QuestpieQueryOptionsConfig, // the config argument
  QuestpieQueryErrorMap,      // (error: unknown) => unknown
  RealtimeQueryConfig,        // { realtime?: boolean }
  // Realtime topic helpers (re-exported from questpie/client):
  TopicConfig,
  RealtimeAPI,
} from "@questpie/tanstack-query";

import { buildCollectionTopic, buildGlobalTopic } from "@questpie/tanstack-query";

QuestpieQueryOptionsProxy<TApp> is the typed shape of q; you rarely name it directly because the scaffold exports typeof q. buildCollectionTopic / buildGlobalTopic are runtime values (re-exported from questpie/client) for building realtime topics manually.

The proxies don't enumerate

q.collections, q.globals, and q.routes are JS Proxy objects with a get-trap only. Object.keys(q.collections), spreading, or for…in over them returns nothing useful, access builders by name (q.collections.posts). q.custom and q.key are plain.

  • Getting started, TanStack Start, scaffolds client + q and runs your first typed query.
  • Collections, the CRUD surface (find, create, updateMany, …) these builders wrap, and the find() envelope shape.
  • Globals, the singleton counterpart, and why get() is non-nullable.
  • Routes, custom server functions exposed as q.routes.<path>.query() / .mutation().
  • Relations, the with hydration and where query language available inside find() options.

On this page