Build Your BackendRules
Hooks
Lifecycle hooks for collections — beforeValidate, beforeChange, afterChange, beforeDelete, afterDelete.
Hooks let you run logic at specific points in the collection lifecycle. They receive the full typed AppContext through context injection — no imports needed.
Hook Lifecycle
API Request
↓
beforeValidate — Modify/validate data before schema validation
↓
Schema Validation — Zod validation from field definitions
↓
beforeChange — Transform data before database write
↓
Database Write — Insert or update
↓
afterChange — Side effects after successful writeFor deletes:
beforeDelete → Database Delete → afterDeleteDefining Hooks
collections/appointments.ts
.hooks({
beforeValidate: async (ctx) => {
// Modify data before validation
if (ctx.data.name && !ctx.data.slug) {
ctx.data.slug = slugify(ctx.data.name);
}
},
beforeChange: async ({ data, operation, original }) => {
if (operation === "create") {
// Set defaults on create
}
if (operation === "update" && original) {
// Compare with original data
}
},
afterChange: async ({ data, operation, original, queue }) => {
if (operation === "create") {
await queue.sendAppointmentConfirmation.publish({
appointmentId: data.id,
customerId: data.customer,
});
}
if (operation === "update" && original) {
if (data.status === "cancelled") {
await queue.sendAppointmentCancellation.publish({
appointmentId: data.id,
customerId: data.customer,
});
}
}
},
beforeDelete: async ({ id }) => {
// Prevent deletion or clean up
},
afterDelete: async ({ id }) => {
// Clean up related data
},
})Hook Context
Every hook receives the full AppContext via destructuring:
| Property | Available in | Description |
|---|---|---|
data | before/afterChange, beforeValidate | The record data being written |
operation | before/afterChange | "create" or "update" |
original | before/afterChange (update only) | Previous record state |
id | before/afterDelete | ID of record being deleted |
collections | All | Typed collection API |
globals | All | Typed globals API |
queue | All | Queue client for publishing jobs |
email | All | Email service |
db | All | Database instance |
session | All | Current auth session |
Plus any custom services defined in services/.
Context-First Pattern
Hooks use context-first injection. All dependencies are available through destructuring — no need to import the app instance:
collections/blog-posts.ts
.hooks({
beforeChange: async ({ data, services }) => {
// `services.blog` is a custom service from services/blog.ts
const { blog } = services;
data.slug = blog.generateSlug(data.title);
data.readingTime = blog.computeReadingTime(data.content);
data.excerpt = blog.extractExcerpt(data.content);
},
afterChange: async ({ data, operation, original, queue }) => {
// Publish job when status transitions to "published"
if (
operation === "update" &&
original?.status !== "published" &&
data.status === "published"
) {
await queue.notifyBlogSubscribers.publish({
postId: data.id,
title: data.title,
excerpt: data.excerpt,
slug: data.slug,
});
}
},
})Real-World Examples
Auto-generate slug
.hooks({
beforeValidate: async (ctx) => {
if (ctx.data.name && !ctx.data.slug) {
ctx.data.slug = slugify(ctx.data.name);
}
},
})Send email on status change
.hooks({
afterChange: async ({ data, operation, original, queue }) => {
if (operation === "create") {
await queue.sendAppointmentConfirmation.publish({
appointmentId: data.id,
customerId: data.customer,
});
}
if (operation === "update" && data.status === "cancelled") {
await queue.sendAppointmentCancellation.publish({
appointmentId: data.id,
customerId: data.customer,
});
}
},
})Compute derived fields
.hooks({
beforeChange: async ({ data, services }) => {
data.readingTime = services.blog.computeReadingTime(data.content);
data.excerpt = services.blog.extractExcerpt(data.content);
},
})Related Pages
- Validation — Field-level validation
- Access Control — Operation permissions
- Jobs — Background tasks triggered by hooks
- Routes — Server-side business logic