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

For deletes:

beforeDelete → Database Delete → afterDelete

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

PropertyAvailable inDescription
databefore/afterChange, beforeValidateThe record data being written
operationbefore/afterChange"create" or "update"
originalbefore/afterChange (update only)Previous record state
idbefore/afterDeleteID of record being deleted
collectionsAllTyped collection API
globalsAllTyped globals API
queueAllQueue client for publishing jobs
emailAllEmail service
dbAllDatabase instance
sessionAllCurrent 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);
  },
})

On this page