QUESTPIE
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:

MethodPurpose
.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);
			}
		},
	});

On this page