Dyrected
Deployment & OperationsServer RuntimeHooks

Collections

Use collection hooks to shape reads, validate writes, and run side effects around a collection document's lifecycle.

Collection hooks are the document-level lifecycle hooks for repeatable content. Use them when the logic belongs to the whole document or to the operation itself: filtering reads, validating a write against several fields, stamping derived data, checking references before delete, or kicking off follow-up work after a save succeeds.

By the end of this page, you should know what each collection hook sees, what it is allowed to change, and which phase is the safest fit for the job.

The shape

Collection hooks live under the collection's hooks key:

import { defineCollection, defineTextField } from '@dyrected/core'

export const Posts = defineCollection({
  slug: 'posts',
  fields: [
    defineTextField({ name: 'title', label: 'Title', required: true }),
    defineTextField({ name: 'slug', label: 'Slug' }),
  ],
  hooks: {
    beforeRead: [],
    afterRead: [],
    beforeChange: [],
    afterChange: [],
    beforeDelete: [],
    afterDelete: [],
  },
})

Each key accepts an array of hooks that run in order. In self-hosted projects, every collection hook can use the function form. For beforeRead, afterRead, and beforeChange, you can also use a declarative string form when you want the hook to sync cleanly to Dyrected Cloud.

If you are targeting Cloud, read Cloud-safe hooks alongside this page. That page is the canonical guide for what survives sync:schema and what gets stripped.

The six lifecycle phases

beforeRead

beforeRead runs before Dyrected queries the database for a list or a single document. It receives the current query object and can return a replacement filter.

Use it when you want to narrow or rewrite reads at the hook layer. The function form is the most flexible:

hooks: {
  beforeRead: [
    ({ query, user }) => {
      if (user?.roles?.includes('admin')) return query
      return {
        AND: [
          query ?? {},
          { status: { equals: 'published' } },
        ],
      }
    },
  ],
}

This is the right place to adjust what is fetched. It is not the right place for side effects.

If you want the same behavior to work in Cloud, use a declarative string hook instead:

hooks: {
  beforeRead: [
    "user != null ? query : { status: { equals: 'published' } }",
  ],
}

For Cloud-safe beforeRead details, including the exact expression context, see Cloud-safe hooks.

Reach for access control first when the question is who is allowed to see a document at all. Reach for beforeRead when the question is how an already-allowed read should be shaped or narrowed at the hook layer.

afterRead

afterRead runs after a document is fetched and before it is returned from the API. On list endpoints, it runs once per document.

Use it when the stored data is correct as-is, but the response should be shaped differently. The function form looks like this:

hooks: {
  afterRead: [
    ({ doc, user }) => {
      if (user?.roles?.includes('admin')) return doc
      return { ...doc, internalNotes: undefined }
    },
  ],
}

afterRead receives a read-only database adapter. It can look things up, but it cannot write.

For Cloud-safe shaping, use a declarative string hook:

hooks: {
  afterRead: [
    "user != null ? doc : { internalNotes: null }",
  ],
}

For Cloud-safe afterRead details, including merge behavior for object returns, see Cloud-safe hooks.

beforeChange

beforeChange runs before a create or update is written. It receives the incoming data, the existing doc on updates, and the operation ('create' or 'update').

This is the main server-side normalization and validation phase:

function slugify(value: string) {
  return value
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '')
}

hooks: {
  beforeChange: [
    ({ data, operation, doc }) => {
      const title = typeof data.title === 'string' ? data.title : doc?.title
      if (!title) return data

      return {
        ...data,
        slug: slugify(title),
        updatedAtLabel: operation === 'create' ? 'created' : 'updated',
      }
    },
  ],
}

Throw from beforeChange to stop the write entirely.

If you want the same transform to survive Cloud sync, use the declarative string form:

hooks: {
  beforeChange: [
    "{ slug: data.title }",
  ],
}

For Cloud-safe beforeChange details, including the exact expression context and merge behavior, see Cloud-safe hooks.

afterChange

afterChange runs after a create or update is committed. It receives the saved doc, the previousDoc on updates, the operation, and a writable database adapter.

Use it for side effects that should only happen after the document is real:

async function notifySearchIndex(doc: { id: string }) {
  await fetch(`https://example.com/search/reindex/${doc.id}`, { method: 'POST' })
}

hooks: {
  afterChange: [
    async ({ doc, operation, previousDoc }) => {
      if (operation === 'update' && previousDoc?.title === doc.title) return
      await notifySearchIndex(doc)
    },
  ],
}

If an afterChange hook fails, Dyrected logs the error and keeps the write. That makes it the safe place for webhooks, cache busting, emails, and follow-up writes.

afterChange is still function-only. It is not part of the Cloud-safe declarative subset because it exists for side effects after persistence.

beforeDelete

beforeDelete runs before a document is removed. Use it to enforce invariants such as "this record cannot be deleted while something else still points at it."

hooks: {
  beforeDelete: [
    async ({ id, db }) => {
      const refs = await db.find({
        collection: 'posts',
        where: { category: { equals: id } },
        limit: 1,
      })

      if (refs.total > 0) {
        throw new Error('Delete the related posts first.')
      }
    },
  ],
}

Like beforeChange, this phase gets a read-only database adapter and can abort the operation by throwing.

beforeDelete is still function-only.

afterDelete

afterDelete runs after the deletion has already happened. Use it for cleanup work that should not block the delete itself.

hooks: {
  afterDelete: [
    async ({ doc, db }) => {
      await db.create({
        collection: 'audit-log',
        data: {
          action: 'deleted-category',
          deletedId: doc.id,
        },
      })
    },
  ],
}

This phase gets a writable database adapter, and its errors are isolated the same way as afterChange.

afterDelete is still function-only.

What the phases can safely do

HookReads from DBWrites to DBReturn value usedBest use
beforeReadYes, read-onlyNoYesRewrite the query before fetch
afterReadYes, read-onlyNoYesReshape the returned document; on list reads, runs once per document
beforeChangeYes, read-onlyNoYesValidate and normalize incoming data
afterChangeYesYesNoSide effects after save
beforeDeleteYes, read-onlyNoNoBlock unsafe deletes
afterDeleteYesYesNoCleanup after delete

A few practical rules

  • Prefer beforeChange for anything that must affect the stored data.
  • Prefer afterChange for anything that talks to another system.
  • Prefer declarative string hooks on beforeRead, afterRead, and beforeChange when you want the same behavior in Cloud.
  • Use previousDoc in afterChange when the side effect depends on what changed.
  • Keep access decisions in access control when the question is allow or deny. Use hooks when the question is transform, validate, react, or reshape an already-authorized operation.

On this page

Dyrected| Cloud

Get your backend ready in minutes

Use a managed database, storage, APIs, and admin dashboard without setting up the infrastructure yourself.

Set Up My Backend