Dyrecteddyrected
Guides

Using Hooks

Practical patterns for server-side and Admin UI hooks — from auto-slugs to cascading dropdowns.

Hooks are functions Dyrected runs at set points in a document's lifecycle — before a save, after a save, before a delete — so you can reshape data, enforce rules, or trigger side effects. This guide is a cookbook of the patterns you'll reach for most, from auto-slugs to cascading dropdowns. Each one is self-contained, so jump straight to what you need.

For the full API reference see Core Concepts → Hooks.


Auto-generate a slug from a title

A beforeChange collection hook lets you derive the slug from the title on every create, and only update it on edits when the title actually changed.

import slugify from "slugify";

export const Posts = defineCollection({
  slug: "posts",
  hooks: {
    beforeChange: [
      ({ data, operation }) => {
        // Always set on create; only update when title is included in the patch
        if (operation === "create" || data.title !== undefined) {
          return {
            ...data,
            slug: slugify(data.title, { lower: true, strict: true }),
          };
        }
        return data;
      },
    ],
  },
  fields: [
    { name: "title", label: "Title", type: "text", required: true },
    { name: "slug", label: "Slug", type: "text" },
  ],
});

To do the same live in the Admin UI (so editors see the slug update as they type), add an onChange hook to the slug field:

{
  name: 'slug',
  type: 'text',
  admin: {
    hooks: {
      onChange: ({ siblingData }) =>
        (siblingData.title ?? '')
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, '-')
          .replace(/(^-|-$)/g, ''),
    },
  },
}

The server hook guarantees the correct value is stored even when documents are created via the API; the UI hook gives instant feedback in the form.


Normalise data before saving

Use field beforeChange hooks to clean up values before they hit the database.

// Lowercase and trim an email
{
  name: 'email',
  type: 'email',
  hooks: {
    beforeChange: [({ value }) => value?.toLowerCase().trim()],
  },
}

// Remove leading/trailing whitespace from any text field
{
  name: 'title',
  type: 'text',
  hooks: {
    beforeChange: [({ value }) => value?.trim()],
  },
}

// Clamp a number to a valid range
{
  name: 'discount',
  type: 'number',
  hooks: {
    beforeChange: [({ value }) => Math.min(100, Math.max(0, Number(value) || 0))],
  },
}

Hash a password field

Never store passwords in plain text. A field beforeChange hook intercepts the value before it is written and returns the hash instead.

import bcrypt from 'bcrypt'

{
  name: 'password',
  type: 'text',
  hooks: {
    beforeChange: [
      async ({ value }) => {
        if (!value) return value
        return bcrypt.hash(value, 10)
      },
    ],
  },
}

Validate data and abort the save

Throw an error from any beforeChange hook to cancel the operation. The database write never happens and the API returns the error message.

export const Orders = defineCollection({
  slug: "orders",
  hooks: {
    beforeChange: [
      ({ data }) => {
        if (data.quantity < 1) {
          throw new Error("Quantity must be at least 1.");
        }
        if (data.total < 0) {
          throw new Error("Order total cannot be negative.");
        }
        return data;
      },
    ],
  },
  fields: [
    { name: "quantity", label: "Quantity", type: "number" },
    { name: "total", label: "Total", type: "number" },
  ],
});

Compute a derived field (total price)

Chain a collection beforeChange hook after individual field hooks to derive a value from the full data payload.

export const LineItems = defineCollection({
  slug: "line-items",
  hooks: {
    beforeChange: [
      ({ data }) => ({
        ...data,
        total: (Number(data.quantity) || 0) * (Number(data.unitPrice) || 0),
      }),
    ],
  },
  fields: [
    { name: "quantity", label: "Quantity", type: "number" },
    { name: "unitPrice", label: "Unit Price", type: "number" },
    { name: "total", label: "Total", type: "number", admin: { readOnly: true } },
  ],
});

Show the computed value live in the Admin UI with an onChange hook:

{
  name: 'total',
  type: 'number',
  admin: {
    readOnly: true,
    hooks: {
      onChange: ({ siblingData }) =>
        (Number(siblingData.quantity) || 0) * (Number(siblingData.unitPrice) || 0),
    },
  },
}

Send a webhook after a document is saved

afterChange is the right place for side-effects that should not block the save itself.

export const Posts = defineCollection({
  slug: 'posts',
  hooks: {
    afterChange: [
      async ({ doc, operation }) => {
        await fetch('https://hooks.example.com/content', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ event: operation, doc }),
        })
      },
    ],
  },
  fields: [
    { name: 'title', label: 'Title', type: 'text', required: true },
  ],
})

Revalidate a Next.js page after a save

The same afterChange timing is what you want for cache invalidation: once the document is saved, tell your site to rebuild the affected page. This hook posts the saved slug to a Next.js revalidation route:

hooks: {
  afterChange: [
    async ({ doc }) => {
      await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/revalidate`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'x-revalidate-secret': process.env.REVALIDATE_SECRET!,
        },
        body: JSON.stringify({ path: `/blog/${doc.slug}` }),
      })
    },
  ],
}

React to create vs update separately

operation tells you whether this is a new document or an update to an existing one.

hooks: {
  beforeChange: [
    ({ data, operation }) => {
      if (operation === 'create') {
        // Set immutable fields only on create
        return { ...data, createdBy: data.user?.sub ?? null, status: 'draft' }
      }
      // Prevent editors from overwriting the original author on update
      const { createdBy, ...rest } = data
      return rest
    },
  ],
}

Track what changed on an update

afterChange receives previousDoc — the snapshot before the write. Use it to detect specific field changes and act on them.

hooks: {
  afterChange: [
    async ({ doc, previousDoc, operation }) => {
      if (operation === 'update' && doc.status !== previousDoc?.status) {
        // Status changed — notify downstream systems
        await notifyStatusChange({ id: doc.id, from: previousDoc.status, to: doc.status })
      }
    },
  ],
}

Prevent a deletion when references exist

Throw inside beforeDelete to cancel the delete. The document is not removed.

export const Categories = defineCollection({
  slug: 'categories',
  hooks: {
    beforeDelete: [
      async ({ id }) => {
        const posts = await db.find({
          collection: 'posts',
          where: { category: { equals: id } },
        })
        if (posts.total > 0) {
          throw new Error(
            `Cannot delete: ${posts.total} post(s) still reference this category.`
          )
        }
      },
    ],
  },
  fields: [
    { name: 'name', label: 'Name', type: 'text', required: true },
  ],
})

Mask sensitive data based on role

Use a field afterRead hook to redact values that should not be visible to non-admin users.

{
  name: 'apiKey',
  type: 'text',
  hooks: {
    afterRead: [
      ({ value, user }) => {
        if (!user?.roles?.includes('admin')) return '••••••••'
        return value
      },
    ],
  },
}

The same pattern works for hiding a field entirely:

{
  name: 'internalNotes',
  type: 'textarea',
  hooks: {
    afterRead: [
      ({ value, user }) => (user?.roles?.includes('admin') ? value : undefined),
    ],
  },
}

Add computed fields on read

afterRead on a collection hook lets you attach virtual fields to every document before it is sent to the client.

hooks: {
  afterRead: [
    ({ doc }) => ({
      ...doc,
      fullName: `${doc.firstName ?? ''} ${doc.lastName ?? ''}`.trim(),
      isPublished: doc.status === 'published' && !!doc.publishedAt,
    }),
  ],
}

Filter the query in beforeRead

Return a modified query from beforeRead to scope all reads for a collection — useful for multi-tenant setups.

hooks: {
  beforeRead: [
    ({ query, user }) => {
      // Each user can only read their own documents
      return { ...query, owner: { equals: user?.sub } }
    },
  ],
}

Cascading dropdowns in the Admin UI

Use admin.hooks.options on a select field to derive its choices from another field's value. No network request is needed — it runs instantly in the browser.

When the parent field changes, any value that is no longer in the new option list is cleared automatically.

export const Locations = defineCollection({
  slug: "locations",
  fields: [
    {
      name: "country",
      label: "Country",
      type: "select",
      options: [
        { label: "United States", value: "us" },
        { label: "Canada", value: "ca" },
      ],
    },
    {
      name: "region",
      label: "Region",
      type: "select",
      options: [],
      admin: {
        hooks: {
          options: ({ siblingData }) => {
            if (siblingData.country === "us") {
              return [
                { label: "California", value: "CA" },
                { label: "New York", value: "NY" },
                { label: "Texas", value: "TX" },
              ];
            }
            if (siblingData.country === "ca") {
              return [
                { label: "Ontario", value: "ON" },
                { label: "Quebec", value: "QC" },
                { label: "British Columbia", value: "BC" },
              ];
            }
            return [];
          },
        },
      },
    },
  ],
});

Three levels deep works the same way — each dependent field reads from siblingData and returns its own list.


Category → sub-type dependent radio

options works on radio fields too:

{
  name: 'subType',
  type: 'radio',
  options: [],
  admin: {
    hooks: {
      options: ({ siblingData }) =>
        siblingData.category === 'vehicle'
          ? [{ label: 'Car', value: 'car' }, { label: 'Truck', value: 'truck' }]
          : [{ label: 'Shirt', value: 'shirt' }, { label: 'Shoes', value: 'shoes' }],
    },
  },
}

Chaining multiple hooks

Hooks in an array run sequentially. Each one receives the output of the previous hook.

hooks: {
  beforeChange: [
    // 1st — normalize
    ({ data }) => ({ ...data, email: data.email?.toLowerCase().trim() }),
    // 2nd — validate (receives normalized data from step 1)
    ({ data }) => {
      if (!data.email.includes('@')) throw new Error('Invalid email address.')
      return data
    },
    // 3rd — stamp the updated timestamp (receives validated data from step 2)
    ({ data }) => ({ ...data, updatedAt: new Date().toISOString() }),
  ],
}

For field hooks the same applies at the value level:

{
  name: 'bio',
  type: 'textarea',
  hooks: {
    beforeChange: [
      ({ value }) => value?.trim(),
      ({ value }) => value?.slice(0, 500),   // enforce max length after trim
    ],
  },
}

Guard a workflow transition

beforeTransition runs before a state change is committed. Throw to cancel the transition — the document stays in its current state and the error is returned to the caller.

import { defineCollection, publishingWorkflow } from "@dyrected/core";

export const Articles = defineCollection({
  slug: "articles",
  workflow: {
    ...publishingWorkflow(),
    hooks: {
      beforeTransition: [
        ({ transition, doc }) => {
          if (transition.name === "submit" && !doc.content) {
            throw new Error(
              "An article must have content before it can be submitted for review.",
            );
          }
        },
      ],
    },
  },
  fields: [
    { name: "title", label: "Title", type: "text", required: true },
    { name: "content", label: "Content", type: "richText" },
  ],
});

Notify after a workflow transition

afterTransition is the right place for emails, webhooks, and other side effects that should fire when a document changes state. The transition has already been committed — throwing here does not roll it back.

import { sendEmail } from "../utils/email";

export const Articles = defineCollection({
  slug: "articles",
  workflow: {
    ...publishingWorkflow(),
    hooks: {
      afterTransition: [
        async ({ doc, transition, user }) => {
          if (transition.name === "submit") {
            // Notify the editorial team that a draft is waiting for review
            await sendEmail({
              to: process.env.EDITORIAL_EMAIL!,
              subject: `New article ready for review: ${doc.title}`,
              body: `Submitted by ${user?.email}. Open the dashboard to review.`,
            });
          }

          if (transition.name === "publish") {
            // Revalidate the page on the live site
            await fetch(`${process.env.SITE_URL}/api/revalidate`, {
              method: "POST",
              headers: {
                "x-revalidate-secret": process.env.REVALIDATE_SECRET!,
              },
              body: JSON.stringify({ path: `/articles/${doc.slug}` }),
            });
          }

          if (transition.name === "reject") {
            // Notify the author that changes were requested
            await sendEmail({
              to: doc.authorEmail,
              subject: `Changes requested on: ${doc.title}`,
              body:
                doc._workflow?.comment ??
                "Please review the feedback and resubmit.",
            });
          }
        },
      ],
    },
  },
  fields: [
    { name: "title", label: "Title", type: "text", required: true },
    { name: "authorEmail", label: "Author Email", type: "email" },
    { name: "content", label: "Content", type: "richText" },
  ],
});

Workflow hooks are only available on collections that have a workflow config. The transition, from, and to arguments tell you exactly what changed — use them to send targeted notifications rather than firing on every state change.


Sync a global setting after it changes

Globals support the same afterChange hook as collections. Since globals often drive layout-wide content, this is a natural place to purge a CDN cache so the change shows up everywhere at once:

export const SiteSettings = defineGlobal({
  slug: "site-settings",
  hooks: {
    afterChange: [
      async ({ doc }) => {
        // Bust the edge cache when site settings are updated
        await fetch(`${process.env.CDN_PURGE_URL}`, {
          method: "POST",
          headers: { Authorization: `Bearer ${process.env.CDN_TOKEN}` },
          body: JSON.stringify({ paths: ["/"] }),
        });
      },
    ],
  },
  fields: [
    { name: "siteName", label: "Site Name", type: "text" },
    { name: "tagline", label: "Tagline", type: "text" },
  ],
});

On this page