Build Your BackendData Modeling
Collections
Collections are database-backed data models with CRUD operations, hooks, access control, and admin UI.
A collection is a data model backed by a database table. Each collection file defines fields, indexes, hooks, access rules, and admin UI in one place.
Defining a Collection
collections/services.ts
import { collection } from "#questpie/factories";
export const services = collection("services")
.fields(({ f }) => ({
name: f.text(255).required().localized(),
description: f.textarea().localized(),
duration: f.number().required(),
price: f.number().required(),
isActive: f.boolean().default(true).required(),
}))
.title(({ f }) => f.name)
.admin(({ c }) => ({
label: { en: "Services", sk: "Služby" },
icon: c.icon("ph:scissors"),
}));Builder Chain
Collections use a chainable builder API. Each method adds a layer:
| Method | Purpose |
|---|---|
.fields(({ f }) => {...}) | Define data fields |
.title(({ f }) => f.name) | Record display title |
.admin(({ c }) => {...}) | Admin UI metadata (label, icon) |
.indexes(({ table }) => [...]) | Database indexes |
.list(({ v, f }) => v.collectionTable({...})) | List view config |
.form(({ v, f }) => v.collectionForm({...})) | Form view config |
.hooks({...}) | Lifecycle hooks |
.access({...}) | Access control rules |
.preview({...}) | Live preview config |
.options({...}) | Timestamps, versioning, soft delete |
.title()
Sets which field is used as the display title for records:
.title(({ f }) => f.name).admin()
Configures how the collection appears in the admin panel:
.admin(({ c }) => ({
label: { en: "Barbers", sk: "Holiči" },
icon: c.icon("ph:users"),
hidden: false, // hide from sidebar
})).indexes()
Add database indexes using Drizzle:
import { uniqueIndex } from "drizzle-orm/pg-core";
.indexes(({ table }) => [
uniqueIndex("barbers_slug_unique").on(table.slug),
uniqueIndex("barbers_email_unique").on(table.email),
]).preview()
Enable live preview for frontend rendering:
.preview({
enabled: true,
position: "right",
defaultWidth: 50,
url: ({ record }) => `/barbers/${record.slug}?preview=true`,
}).options()
Configure collection-level behavior:
.options({
timestamps: true, // adds createdAt, updatedAt
versioning: true, // track content versions
softDelete: true, // mark as deleted instead of removing
})CRUD Operations
Collections provide typed CRUD methods on the server side:
// In a function/hook handler via context
const { collections } = context;
// Find many
const results = await collections.services.find({
where: { isActive: true },
orderBy: { name: "asc" },
limit: 10,
offset: 0,
});
// results.docs: Service[]
// results.totalDocs: number
// Find one
const service = await collections.services.findOne({
where: { id: "abc123" },
});
// Create
const newService = await collections.services.create({
name: "Haircut",
duration: 30,
price: 2500,
isActive: true,
});
// Update
const updated = await collections.services.update({
where: { id: "abc123" },
data: { price: 3000 },
});
// Delete
await collections.services.delete({
where: { id: "abc123" },
});
// Count
const count = await collections.services.count({
where: { isActive: true },
});Real-World Example
From the barbershop example — a complete collection with all builder methods:
collections/barbers.ts
import { uniqueIndex } from "drizzle-orm/pg-core";
import { collection } from "#questpie/factories";
import { slugify } from "@/questpie/server/utils";
export const barbers = collection("barbers")
.fields(({ f }) => {
const daySchedule = {
isOpen: f.boolean().default(true),
start: f.time(),
end: f.time(),
};
return {
name: f.text(255).required(),
slug: f.text(255).required().inputOptional(),
email: f.email().required(),
phone: f.text(50),
bio: f.textarea().localized(),
avatar: f.upload({
to: "assets",
mimeTypes: ["image/*"],
maxSize: 5_000_000,
}),
isActive: f.boolean().default(true).required(),
workingHours: f.object({
monday: f.object(daySchedule),
tuesday: f.object(daySchedule),
// ... other days
}),
socialLinks: f
.object({
platform: f.select(["instagram", "facebook", "twitter"]),
url: f.url(),
})
.array()
.maxItems(5),
services: f.relation("services").manyToMany({
through: "barberServices",
sourceField: "barber",
targetField: "service",
}),
};
})
.indexes(({ table }) => [
uniqueIndex("barbers_slug_unique").on(table.slug),
uniqueIndex("barbers_email_unique").on(table.email),
])
.title(({ f }) => f.name)
.admin(({ c }) => ({
label: { en: "Barbers", sk: "Holiči" },
icon: c.icon("ph:users"),
}))
.preview({
enabled: true,
position: "right",
defaultWidth: 50,
url: ({ record }) => `/barbers/${record.slug}?preview=true`,
})
.list(({ v }) => v.collectionTable({}))
.form(({ v, f }) =>
v.collectionForm({
sidebar: {
position: "right",
fields: [f.isActive, f.avatar],
},
fields: [
{
type: "section",
label: { en: "Contact Information" },
layout: "grid",
columns: 2,
fields: [f.name, f.slug, f.email, f.phone],
},
{
type: "section",
label: { en: "Profile" },
fields: [f.bio],
},
{
type: "section",
label: { en: "Services" },
fields: [f.services],
},
],
}),
)
.hooks({
beforeValidate: async (ctx) => {
if (ctx.data.name && !ctx.data.slug) {
ctx.data.slug = slugify(ctx.data.name);
}
},
});Related Pages
- Fields — All field types and options
- Relations — Relation field deep dive
- Hooks — Lifecycle hooks
- Access Control — Who can do what
- List Views — Admin list configuration
- Form Views — Admin form configuration