Framework 101
What QUESTPIE is, the one-schema mental model, and everything you get out of the box, the page to read first.
QUESTPIE is a server-first TypeScript framework. You define your schema once with file-convention factories, run codegen, and get a typed runtime, an admin UI, a REST API, OpenAPI docs, a typed client, and TanStack Query, all projected from that one schema. Nothing is defined twice.
This page gives you the whole mental model fast, then a scannable tour of every core concept. Read it first; follow the links to go deep on any one.
What you write vs. what you get
You write declarations, plain files that describe collections, fields, access rules, routes, jobs. You run questpie generate. Codegen reads those files and projects them into everything downstream:
The admin panel, the client SDK, and the OpenAPI spec are projections of your schema, the same way a mobile app would be, not separate definitions you keep in sync by hand. Change a field, re-run codegen, and every layer updates with correct types.
One term, one concept
A collection is a database table. A global is a singleton. A field describes one column. A module (or plugin) is config you add. These names mean the same thing on the server, in codegen, in the client, and in the admin, no synonyms.
What you get out of the box
Scaffold a new app with bunx create-questpie my-app and you ship from a real starting point, not an empty shell. Every scaffold boots immediately with:
- Auth, server + client, Better Auth (email/password) wired as
config/auth.ts, plus a typed auth client. - A typed frontend client,
createClient<AppConfig>(...)with full inference from your schema. - TanStack Query, wired,
createQuestpieQueryOptions(client)gives youqueryOptions/mutationOptionsbuilders for every collection, global, and route. - An admin UI, mounted at
/admin(on the TanStack Start and Next.js runtimes). - OpenAPI + Scalar docs, a live REST reference at
/api/docs. - A branded landing page, at
/, surfacing your entry points (admin, API docs). - Committed codegen output, a
docker-compose(Postgres + extensions),.env, and the full script set (dev,db:push,migrate*).
Pick a runtime and follow its getting-started guide to a booting app.
Four runtimes, one core
TanStack Start and Next.js are fullstack (admin UI included); Hono and Elysia
are headless API + typed-client. The src/questpie/** core is identical
across all four, only a thin runtime adapter (how the handler is mounted)
differs.
Your first collection, end to end
Here is the whole payoff in three steps: define a collection, generate, query it with full types.
1. Define the schema. Collection files live in src/questpie/server/collections/. The f argument is the field builder, you destructure it from the .fields() callback, you never import individual field factories.
import { collection } from "#questpie/factories";
export const posts = collection("posts")
.fields(({ f }) => ({
title: f.text(255).required(),
body: f.richText(),
author: f.relation("user"),
status: f.select([
{ value: "draft", label: "Draft" },
{ value: "published", label: "Published" },
]),
publishedAt: f.datetime(),
}))
.title(({ f }) => f.title);Import from `#questpie/factories`, not `questpie`
Codegen generates the collection() factory so that module-contributed field
types (f.richText(), f.blocks() from the admin module) appear on f. A
bare import {collection} from "questpie" only sees the 15 built-in field
types. Always import from #questpie/factories. See
Codegen for how the generated factory is wired.
2. Run codegen. This discovers your files and writes the typed runtime to src/questpie/server/.generated/.
questpie generate3. Query it with the typed client. Your where, orderBy, with, and the result are all inferred from the schema above:
import { client } from "@/lib/client";
const result = await client.collections.posts.find({
where: { status: "published" },
orderBy: { publishedAt: "desc" },
with: { author: true },
limit: 10,
});
result.docs[0].title;
// ^ string
result.docs[0].author;
// ^ the related user row, typed, because of `with: { author: true }`find() returns a paginated envelope: { docs, totalDocs, totalPages, page, hasNextPage, ... }. That same schema now also serves GET /api/posts, appears in /api/docs, and renders a list + form in the admin, with zero extra code.
The 101 tour, every core concept
A one-line map of the whole framework. Each entry links to its dedicated page.
Data modeling
Collections, typed, versioned database tables with hooks, access rules, relations, and (with the admin module) an automatic admin UI. The collection(name) builder is the unit you compose everything else onto.
collection("posts").fields(({ f }) => ({ title: f.text().required() }));Globals, singletons (site settings, a homepage). Same field API as collections, but get/update only, no create/delete, no where.
global("settings").fields(({ f }) => ({ siteName: f.text().required() }));Fields, the building blocks of a row. 15 built-ins cover the common cases; chain modifiers like .required(), .default(), .localized(), and .array() on any of them.
| Field | What it stores |
|---|---|
f.text(max?) / f.textarea() | strings (varchar / unbounded) |
f.email() / f.url() | validated strings |
f.number(mode?) | integer, decimal, bigint, … |
f.boolean() | true/false |
f.date() / f.datetime() / f.time() | dates and times |
f.select([...]) | a literal-union enum (add .array() for multi-select) |
f.relation(target) | a typed foreign key (see Relations) |
f.upload({ to }) | a file reference into an assets collection |
f.object({...}) / f.json() | nested JSONB (typed or schema-less) |
f.richText() / f.blocks() | admin-module rich content |
Relations, typed foreign keys. f.relation("user") is a belongs-to; .hasMany({ foreignKey }) and .manyToMany({ through }) give you the other directions. Load them with with: { author: true }.
author: f.relation("user");
comments: f.relation("comment").hasMany({ foreignKey: "postId" });Relation targets are validated after codegen
Pre-codegen, f.relation("usr") is just a string. Once codegen runs, the
target name is validated against your real collections, a typo becomes a
compile error.
Rules and lifecycle
Access control, per-operation rules on .access({ read, create, update, delete }). Return a boolean to allow/deny, or a where-shaped object to filter rows (row-level security). Rules run with the full request context (session, db, …).
collection("posts").access({
read: true,
update: ({ session }) => !!session?.user,
});Secure by default, except routes
An undefined access rule on a collection requires a session, it does not
default to public. Routes are the opposite: a route with no .access() is
public until you add one. Be deliberate about both. See Access
control and Routes.
Hooks, lifecycle callbacks on .hooks({ ... }): beforeChange, afterChange, beforeDelete, and more. Each receives the full app context. Use onAfterCommit(cb) for side effects (jobs, emails, search) so they only fire once the transaction is durable.
collection("posts").hooks({
afterChange: async ({ data, queue, onAfterCommit }) => {
onAfterCommit(() => queue.notifySubscribers.publish({ postId: data.id }));
},
});Don't run output hooks inside an open transaction
Block/upload afterRead work re-entering an open tx can deadlock the database
driver. Schedule durable-data side effects with onAfterCommit instead. See
Hooks.
Validation, Zod schemas for create and update are generated automatically from your field definitions. Use .validation({ refine, exclude }) only when you need to tighten or drop a specific field.
Business logic
Routes, custom HTTP endpoints as files in src/questpie/server/routes/. Chain .post().schema(...).handler(...); the handler gets a typed input plus the full context (collections, queue, db, session). No imports, no singletons, everything arrives through context.
import { route } from "questpie/services";
import { z } from "zod";
export default route()
.post()
.schema(z.object({ postId: z.string() }))
.handler(async ({ input, collections }) => {
return collections.posts.findOne({ where: { id: input.postId } });
});Jobs, background work as files in jobs/. Define a payload schema and handler; dispatch with queue.<name>.publish(payload). Add options.cron for a recurring schedule. Adapters: pg-boss, BullMQ, Cloudflare Queues.
import { job } from "questpie";
import { z } from "zod";
export default job({
name: "send-digest",
schema: z.object({ userId: z.string() }),
handler: async ({ payload, email }) => {
/* … */
},
});There is no `questpie worker` CLI command
Workers start in code. Write a script that imports your built app and calls
app.queue.listen() (long-running) or app.queue.runOnce() (one serverless
batch). See Jobs.
Services, reusable singletons (a Stripe client, a third-party SDK) injected into context. Dependencies come from the create(ctx) argument, there is no separate deps array.
import { service } from "questpie/services";
export default service({
create: ({ db }) => new BillingClient(db),
});Emails, typed templates as emails/*.ts. A template returns { subject, html }; send it with email.sendTemplate({ template, to, input }). Adapters: SMTP, Resend, Plunk, or a dev console logger.
import { email } from "questpie/services";
import { z } from "zod";
export default email({
name: "welcome",
schema: z.object({ name: z.string() }),
handler: ({ input }) => ({ subject: `Welcome, ${input.name}`, html: "…" }),
});Blocks, composable content blocks for page-builder fields (admin module). Declare a block with block(name).fields(...), then store a tree of them in a f.blocks() field.
The client
Typed client SDK, createClient<AppConfig>(...) from questpie/client. Gives you client.collections.*, client.globals.*, client.routes.*, client.search, and client.realtime, every method typed from your schema. AppConfig is the generated type of your app.
import { createClient } from "questpie/client";
import type { AppConfig } from "#questpie";
export const client = createClient<AppConfig>({ baseURL, basePath: "/api" });TanStack Query, createQuestpieQueryOptions(client) from @questpie/tanstack-query builds queryOptions/mutationOptions you pass straight into useQuery/useMutation. Query keys, locale, and stage are handled for you. Pass { realtime: true } to find/count/get for live updates.
const { data } = useQuery(q.collections.posts.find({ limit: 10 }));
const create = useMutation(q.collections.posts.create());Realtime, client.collections.posts.live(query, onSnapshot) opens a live subscription; the first snapshot arrives immediately, then updates stream over a single multiplexed SSE connection.
Infrastructure (adapters)
QUESTPIE talks to infrastructure through adapters, you swap the backend without touching your app code. Each is configured once in questpie.config.ts.
| Concern | Built-in / default | Other adapters |
|---|---|---|
| Queue | none | pg-boss, BullMQ, Cloudflare Queues |
| Search | Postgres (FTS + trigram) | pgvector (semantic) |
| Realtime | poll-based | Postgres NOTIFY, Redis Streams |
| Storage | local filesystem | S3, R2 (via files-sdk) |
| console (dev) | SMTP, Resend, Plunk | |
| KV | in-memory | Redis, Cloudflare KV |
import { runtimeConfig } from "questpie/app";
import { pgBossAdapter } from "questpie/adapters/pg-boss";
export default runtimeConfig({
queue: {
adapter: pgBossAdapter({ connectionString: process.env.DATABASE_URL }),
},
});The admin panel
Admin UI, add the @questpie/admin module and your collections get a full panel: list views, forms, filters, and media, all generated from the same schema via introspection. Customize per collection with .admin(), .list(), .form(), and .actions(); the admin's own collections follow the exact same file conventions as yours.
Extending the framework
Modules and plugins, a module is plain data (collections, routes, jobs, services, a codegen plugin) you add to src/questpie/server/modules.ts. Admin, OpenAPI, MCP, and workflows all use this path. Build your own to package reusable schema + behavior.
Codegen, the engine that turns your files into the typed runtime. questpie generate runs it once; questpie dev watches and regenerates on file add/remove. Codegen output is committed to git. A module's codegen plugin lets a package contribute its own file conventions without you editing config.
Re-run codegen after adding or removing files
New collections, routes, and jobs only enter the typed runtime once questpie generate (or questpie dev) regenerates .generated/. Editing the body of
an existing file does not require a regen, only adding/removing files does.
Where to next
- Getting started: TanStack Start, scaffold →
docker compose up→db:push→ a booting app. - Collections, the deep dive on the unit you'll use most.
- Fields, every built-in field type and the chain-modifier model.
- Client SDK, the typed client and how every call is inferred from your schema.
- Codegen, the engine that turns your files into the typed runtime.
Suggested learning paths
Pick the on-ramp that matches what you're here to do. Each is an ordered path through the canonical pages, read them in sequence and every step builds on the last.
- New dev (build something), TanStack Start → Collections → Fields → Relations → Access control → Client SDK → TanStack Query. Add Hooks, Jobs, and Routes when you need behavior.
- Backend / data modeler, Collections → Fields → Relations → Validation → Access control → Hooks.
- Frontend integrator, Client SDK → TanStack Query → Realtime.
- Extender / plugin author, Codegen → Modules → Build a plugin.
- AI agent, the QUESTPIE skills (by name) plus
llms.txt/llms-full.txtare the agent on-ramp.
Documentation
Server-first TypeScript framework with schema-driven codegen. Start with Getting Started, then use the Concepts, Client, Admin, and Extend sections as references.
Getting started, TanStack Start
Scaffold a QUESTPIE app, boot Postgres, push your schema, and run a first typed-client query, from zero to a running full-stack app.