QUESTPIE

Your first collection

One collection file becomes a database table, a set of REST routes, typed client methods and a screen in the admin.

View markdown

This page adds a products collection to an app you already have running. You write one file, run three commands, then reach the same table from three places.

Adding the file

A collection is one database table plus the rules around it. Codegen finds it by its location, so there is no central registry to edit.

To add one, run questpie add from the project root. It writes the file and runs codegen for you:

bun questpie add collection products

That creates src/questpie/server/collections/products.ts with a single title field. Replace it with the fields a product actually has:

src/questpie/server/collections/products.ts
import { collection } from "#questpie/factories";

export const products = collection("products")
	.fields(({ f }) => ({
		name: f.text(255).label("Name").required(),
		description: f.textarea().label("Description"),
		priceCents: f.number().label("Price in cents").required(),
		status: f
			.select([
				{ value: "draft", label: "Draft" },
				{ value: "live", label: "Live" },
			])
			.label("Status")
			.default("draft"),
	}))
	.title(({ f }) => f.name);

.title() tells the admin which field to print as the label of a row. Every collection also gets id, createdAt and updatedAt. You never declare those.

Picking field types

f is the field factory. These are the types you reach for first, all of them from questpie itself with no module to enable.

FieldColumnUse it for
f.text(255)varchar(255)Names, slugs, short strings.
f.textarea()textLong plain text, no length cap.
f.number()integerCounts and cents. Pass { mode: "decimal", precision: 10, scale: 2 } for money.
f.boolean()booleanFlags.
f.select([...])varcharA fixed set of values. They become a TypeScript union.
f.relation("users")foreign keyA link to another collection.

`f.richText()` is not core

Rich text is a field type contributed by @questpie/admin. It appears on f only when that module is enabled. A headless project uses f.textarea().

Declaring who can read it

Access rules sit on the collection, next to the fields. A rule is true, false, or a function returning one of those or a where clause that filters rows.

Nothing you have written so far is public. With no rule and no app-level defaultAccess, QUESTPIE requires an authenticated session and denies anonymous requests. To open reads on live products while keeping writes to signed-in users, chain .access():

src/questpie/server/collections/products.ts
export const products = collection("products")
	.fields(({ f }) => ({
		// ...
	}))
	.title(({ f }) => f.name)
	.access({
		read: () => ({ status: "live" }),
		create: ({ session }) => !!session,
		update: ({ session }) => !!session,
		delete: false,
	});

Returning an object from read filters instead of denying, so anonymous callers get the live rows and never see the drafts. You declare that once, and every surface below obeys it.

Generating and pushing

questpie generate reads the collections directory and rewrites src/questpie/server/.generated/. questpie add ran it once already. Run it again after every hand edit, then create the columns in your local database:

bun run questpie:generate
bun run db:push

`push` is for local development

questpie push diffs your schema straight onto the database and bypasses migration history. --force acknowledges the warning, it does not make the command safe. For anything deployed, run questpie migrate:create, commit the migration, then apply it with questpie migrate.

Opening it in the admin

The admin is @questpie/admin, a module you enable. It is not core. create-questpie turns it on by default for TanStack Start and Next. Hono and Elysia have no render layer, so the module is rejected there and those projects stop at the API and the typed client.

Start the dev server and open http://localhost:3000/admin/collections/products. The list uses the field you passed to .title(). The form picks a control per field type, so status is a select holding your two options and description a textarea.

That URL works before the sidebar does, because @questpie/admin routes by collection name rather than by sidebar membership. The nav is a separate step. The starter lists its sidebar items explicitly, and an explicit list turns off the append of unlisted collections. Add one entry to get products in there:

src/questpie/server/config/admin.ts
{ sectionId: "main", type: "collection", collection: "products" },

Calling it from the client

createClient from questpie/client is core, and typed from the same generated AppConfig, so products exists on it as soon as codegen has run:

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

const { docs, totalDocs } = await client.collections.products.find({
	where: { status: "live" },
	orderBy: { createdAt: "desc" },
	limit: 10,
});

const created = await client.collections.products.create({
	name: "Cast iron pan",
	priceCents: 4900,
});

find() resolves to a paginated envelope carrying docs, totalDocs, page and hasNextPage. create() resolves to the row.

Hitting the REST route

The same collection is a set of routes under your handler's base path, /api in the starter. These five carry the CRUD:

GET    /api/products
GET    /api/products/:id
POST   /api/products
PATCH  /api/products/:id
DELETE /api/products/:id

Query options are bracketed query parameters. The read rule still applies, so this needs no session and returns live products only:

curl "http://localhost:3000/api/products?limit=5&orderBy[createdAt]=desc"

Next

Run work in the background takes the slow half of a request off the request.

On this page