Your first collection
One collection file becomes a database table, a set of REST routes, typed client methods and a screen in the admin.
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 productsThat creates src/questpie/server/collections/products.ts with a single title
field. Replace it with the fields a product actually has:
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.
| Field | Column | Use it for |
|---|---|---|
f.text(255) | varchar(255) | Names, slugs, short strings. |
f.textarea() | text | Long plain text, no length cap. |
f.number() | integer | Counts and cents. Pass { mode: "decimal", precision: 10, scale: 2 } for money. |
f.boolean() | boolean | Flags. |
f.select([...]) | varchar | A fixed set of values. They become a TypeScript union. |
f.relation("users") | foreign key | A 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():
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:
{ 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/:idQuery 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.