Features

Hooks

Hooks let you run custom logic at specific points in the document pipeline. Vextro supports three levels of hooks:

  • Collection hooks operate on the entire document payload -- great for cross-field computation, side effects, and validation.
  • Field hooks operate on individual field values -- ideal for per-field normalization, formatting, and computed displays.
  • Global hooks operate on singleton global documents -- same capabilities as collection hooks but for globals.

All hooks share a mutable hook context object for data sharing within a single invocation. Hooks execute server-side inside Convex functions and are defined inline on collection, field, or global definitions.

Server-side execution

Hooks run inside Convex functions on the server, not in HTTP middleware or browser code. Collection mutation hooks run inside Convex mutations (transactional, read-write). Read hooks run inside Convex queries (non-transactional, read-only). If you are new to Convex, see Key Concepts for background.

Hooks and the Convex execution model

If you are coming from PayloadCMS or another CMS, you may notice that some familiar hooks are missing. This is intentional -- Convex's execution model makes them unnecessary.

Why no afterError hook?

Convex mutations are atomic transactions. If a beforeChange hook throws, the entire mutation rolls back -- no data is written, no partial state exists. There is nothing to clean up, so there is no afterError hook. See Key Concepts: Atomic transactions.

Why no beforeValidate hook?

Convex validates function arguments at the platform level before any handler code runs. The f field builders generate these validators automatically. By the time your beforeChange hook executes, the data has already passed type validation. See Key Concepts: Argument validation.

Why no beforeOperation / afterOperation?

In PayloadCMS, these hooks wrap the HTTP request lifecycle. Convex functions are not HTTP handlers -- each function is an atomic unit of work. The function itself is the operation. Use beforeChange for pre-write logic, afterChange for post-write effects, and beforeRead/afterRead for query-time transforms.

Where are the auth hooks?

PayloadCMS bundles auth and provides hooks like afterLogin and afterLogout. Vextro delegates authentication to Better Auth, which has its own event and hook system. See Authentication for details.


Collection hooks

Collection hooks are defined on the collection definition and fire during document mutations. They receive the full document payload and mutation context.

Available collection hooks

HookFiresCan modify data?Can abort?
beforeChangeBefore create/update writeYes -- return modified dataYes -- throw an error
afterChangeAfter create/update writeYes -- return a patch (triggers second write)No
beforeDeleteBefore deleteNoYes -- throw an error
afterDeleteAfter deleteNoNo
beforeReadBefore locale merge and field hooksYes -- return modified docNo
afterReadAfter field hooks and locale mergeYes -- return modified docNo

Defining collection hooks

Add a hooks object to any collection definition. Each hook type accepts an array of functions, executed in order.

import { defineVextroCollection, f } from "vextro";

export const products = defineVextroCollection({
  label: "Products",
  collectionType: "content",
  tableName: "products",
  fields: {
    name: f.text({ required: true }),
    price: f.number({ required: true }),
    discountPercent: f.number(),
    salePrice: f.number({ readOnly: true }),
  },
  hooks: {
    beforeChange: [
      ({ data }) => {
        const price = Number(data.price ?? 0);
        const discount = Number(data.discountPercent ?? 0);
        const salePrice = discount > 0
          ? Math.round(price * (1 - discount / 100) * 100) / 100
          : price;
        return { ...data, salePrice };
      },
    ],
  },
});

beforeChange

Runs before the database write on both creates and updates. Use it for computed fields, data normalization, or validation.

hooks: {
  beforeChange: [
    ({ data, originalDoc, operation, ctx, collection, collectionSlug }) => {
      // data: the payload about to be written
      // originalDoc: the existing document (undefined on create)
      // operation: "create" or "update"
      // ctx: Convex mutation context
      // collection: admin collection metadata
      // collectionSlug: the collection's slug

      // Return modified data, or void to leave unchanged
      return { ...data, computedField: "value" };
    },
  ],
}

Key behaviors

  • Single write: modifications are applied before the insert/patch, so there is no second database write. This is the preferred hook for computed fields.
  • Chaining: when multiple hooks are defined, each receives the output of the previous hook.
  • System fields: _id and _creationTime are automatically stripped from the returned object to prevent accidental overwrites.
  • Updates receive the patch: for update operations, data contains only the changed fields (the patch), not the full document. Read originalDoc if you need the full document to compute a value.

Common use cases

Computed address field:

beforeChange: [
  ({ data }) => {
    const parts: string[] = [];
    if (data.address1) parts.push(String(data.address1));
    if (data.address2) parts.push(String(data.address2));
    const loc = [
      data.city ? String(data.city) : null,
      data.state && data.zip
        ? `${data.state} ${data.zip}`
        : (data.state ? String(data.state) : null) ??
          (data.zip ? String(data.zip) : null) ??
          null,
    ].filter(Boolean);
    if (loc.length > 0) parts.push(loc.join(", "));
    const result = { ...data };
    if (parts.length > 0) {
      result.address = parts.join(", ");
    }
    return result;
  },
],

Slug normalization:

beforeChange: [
  ({ data }) => {
    if (typeof data.slug === "string") {
      return {
        ...data,
        slug: data.slug.toLowerCase().replace(/\s+/g, "-"),
      };
    }
  },
],

Validation:

beforeChange: [
  ({ data, operation }) => {
    if (operation === "create" && !data.requiredField) {
      throw new Error("requiredField is required on creation");
    }
  },
],

afterChange

Runs after the database write. If a hook returns an object, Vextro applies it as a patch (second write). Use this when the computation depends on the saved document (e.g., the _id).

hooks: {
  afterChange: [
    ({ doc, previousDoc, operation, ctx, collection, collectionSlug }) => {
      // doc: the saved document (full, with _id)
      // previousDoc: the document before the change (undefined on create)
      // operation: "create" or "update"

      // Return a patch object, or void for no additional write
    },
  ],
}

Key behaviors

  • Auto-patch: returned objects are merged and applied as a single ctx.db.patch. Only fields that actually changed are written.
  • No cascading: the auto-patch does not trigger another round of hooks or version snapshots, preventing infinite loops.
  • Prefer beforeChange: if your computation only depends on the incoming data (not the saved _id or _creationTime), use beforeChange instead to avoid the second write.

Example: generate a reference code

afterChange: [
  ({ doc, operation }) => {
    if (operation === "create") {
      const id = String(doc._id).slice(-6).toUpperCase();
      return { referenceCode: `ORD-${id}` };
    }
  },
],

beforeDelete

Runs before a document is deleted. Throw an error to abort the deletion.

hooks: {
  beforeDelete: [
    async ({ id, doc, ctx, collection, collectionSlug }) => {
      // id: the document ID being deleted
      // doc: the full document about to be deleted

      // Throw to abort
      if (doc.status === "published") {
        throw new Error("Cannot delete published documents");
      }
    },
  ],
}

Use cases

  • Prevent deletion of documents in certain states
  • Check for references -- query other tables to see if the document is still referenced
  • Require confirmation -- validate that a special flag was set before allowing delete

afterDelete

Runs after the document has been deleted. Use for cleanup side effects.

hooks: {
  afterDelete: [
    async ({ id, doc, ctx, collection, collectionSlug }) => {
      // Clean up related records
      const related = await ctx.db
        .query("comments")
        .withIndex("by_postId", (q) => q.eq("postId", id))
        .collect();
      for (const comment of related) {
        await ctx.db.delete(comment._id);
      }
    },
  ],
}

Side effects only

afterDelete cannot modify the deleted document (it no longer exists). Use it for cascading deletes, sending notifications, or updating counters in other tables.

beforeRead

Runs before locale merge and field afterRead hooks. Receives the raw document straight from the database. Use it to modify the document before any other read processing.

hooks: {
  beforeRead: [
    ({ doc, ctx, collection, collectionSlug, context }) => {
      // doc: the raw document from the database (all locales, all fields)
      // ctx: Convex query context (read-only)
      // context: shared hook context object

      // Return modified doc, or void to leave unchanged
      return { ...doc, computedField: "value" };
    },
  ],
}

Key behaviors

  • Read-only context: beforeRead hooks run in Convex query functions. The ctx is a GenericQueryCtx with no write access.
  • Runs before locale merge: if localization is configured, the document still contains all locale variants at this point.
  • Applied in all query paths: hooks run in getDocumentForCollection (single document), getCollectionPageData (list view), and getCollectionPageDataPaginated (paginated list view).

Example: inject a computed flag

beforeRead: [
  ({ doc }) => {
    const isExpired = doc.expiresAt && Number(doc.expiresAt) < Date.now();
    return { ...doc, isExpired };
  },
],

afterRead

Runs after locale merge and all field afterRead hooks. Transforms the entire document before returning to the client. Use it for cross-field computed display values.

hooks: {
  afterRead: [
    ({ doc, ctx, collection, collectionSlug, context }) => {
      // doc: the document after locale merge and field afterRead hooks
      // ctx: Convex query context (read-only)
      // context: shared hook context object

      // Return modified doc, or void to leave unchanged
      return { ...doc, fullName: `${doc.firstName} ${doc.lastName}` };
    },
  ],
}

Key behaviors

  • Read-only context: same as beforeRead, runs in query context.
  • Runs after field hooks: field afterRead hooks have already transformed individual field values at this point.
  • Applied in all query paths: hooks run in all document query functions.

Example: add a display label

afterRead: [
  ({ doc }) => {
    const parts = [doc.city, doc.state, doc.country].filter(Boolean);
    return { ...doc, locationLabel: parts.join(", ") || "Unknown" };
  },
],

Field hooks

Field hooks operate on individual field values rather than the entire document. They are defined inline on any field using the hooks option.

Available field hooks

HookFiresContextPurpose
beforeChangeBefore create/update writeMutation (read-write)Normalize, transform, or compute a field value before it is stored
afterReadWhen a document is readQuery (read-only)Transform a field value for display without changing the stored data
afterChangeAfter create/update writeMutation (read-write)React to a saved field value with side effects
beforeDuplicateDuring document duplicationMutation (read-write)Transform a copied field value before it enters the create pipeline

Defining field hooks

Add a hooks object to any field's options. Each hook type accepts an array of functions.

import { defineVextroCollection, f } from "vextro";

export const contacts = defineVextroCollection({
  label: "Contacts",
  collectionType: "config",
  tableName: "contacts",
  fields: {
    name: f.text({
      required: true,
      hooks: {
        beforeChange: [({ value }) => String(value ?? "").trim()],
      },
    }),
    email: f.email({
      hooks: {
        beforeChange: [({ value }) => {
          if (typeof value === "string") {
            return value.toLowerCase().trim();
          }
        }],
      },
    }),
    internalCode: f.text({
      hooks: {
        afterRead: [({ value }) => {
          if (typeof value === "string") {
            return value.toUpperCase();
          }
        }],
      },
    }),
  },
});

Field beforeChange

Transforms a field value before the database write. Runs on both creates and updates.

hooks: {
  beforeChange: [
    ({ value, originalValue, data, originalDoc, operation, fieldName, ctx, collection, collectionSlug }) => {
      // value: the current field value (from payload or originalDoc on updates)
      // originalValue: the field's value before this change (undefined on create)
      // data: the full document payload
      // originalDoc: the existing document (undefined on create)
      // operation: "create" or "update"
      // fieldName: the name of this field
      // ctx: Convex mutation context

      // Return a new value, or void to keep unchanged
      return transformedValue;
    },
  ],
}

Key behaviors

  • Runs for all hooked fields on updates: on update operations, field hooks run for every field that has hooks defined, not just fields in the patch. For fields not in the patch, value comes from originalDoc. This lets hooks compute derived values even when their source field was not explicitly changed.
  • Runs before collection hooks: field beforeChange hooks execute before collection beforeChange hooks, so collection hooks receive already-normalized field values.
  • Chaining: when multiple hooks are defined, each receives the output of the previous hook. If a hook returns undefined or void, the value passes through unchanged.

Common use cases

Trim whitespace:

name: f.text({
  required: true,
  hooks: {
    beforeChange: [({ value }) => String(value ?? "").trim()],
  },
}),

Normalize email:

email: f.email({
  hooks: {
    beforeChange: [({ value }) => {
      if (typeof value === "string") {
        return value.toLowerCase().trim();
      }
    }],
  },
}),

Enforce numeric range:

rating: f.number({
  hooks: {
    beforeChange: [({ value }) => {
      const n = Number(value ?? 0);
      return Math.max(0, Math.min(5, n));
    }],
  },
}),

Field afterRead

Transforms a field value after reading from the database. The transformation is applied in the query response but does not change the stored data.

hooks: {
  afterRead: [
    ({ value, doc, fieldName, ctx, collection, collectionSlug }) => {
      // value: the stored field value
      // doc: the full document
      // fieldName: the name of this field
      // ctx: Convex query context (read-only)

      // Return a transformed value, or void to keep unchanged
      return displayValue;
    },
  ],
}

Key behaviors

  • Read-only context: afterRead hooks run in Convex query functions, not mutations. The ctx is a GenericQueryCtx with no write access.
  • Applied in all query paths: hooks run in getDocumentForCollection (single document), getCollectionPageData (list view), and getCollectionPageDataPaginated (paginated list view).
  • Does not affect stored data: the original value remains in the database. Only the query response is transformed.

Common use cases

Format for display:

price: f.number({
  hooks: {
    afterRead: [({ value }) => {
      if (typeof value === "number") {
        return Math.round(value * 100) / 100;
      }
    }],
  },
}),

Mask sensitive data:

ssn: f.text({
  hooks: {
    afterRead: [({ value }) => {
      if (typeof value === "string" && value.length > 4) {
        return "***-**-" + value.slice(-4);
      }
    }],
  },
}),

Field afterChange

Runs after the database write. Use it for side effects triggered by individual field value changes, such as sending a notification when a status field changes.

status: f.select({
  options: ["draft", "review", "published"],
  hooks: {
    afterChange: [
      async ({ value, previousValue, doc, operation, fieldName, ctx, context }) => {
        // value: the saved field value
        // previousValue: the field's value before this change (undefined on create)
        // doc: the full saved document (with _id)
        // operation: "create" or "update"
        // fieldName: the name of this field
        // ctx: Convex mutation context (read-write)
        // context: shared hook context object

        if (previousValue !== value && value === "published") {
          // Schedule a notification when status changes to published
          await ctx.scheduler.runAfter(0, internal.notifications.send, {
            type: "published",
            documentId: doc._id,
          });
        }
      },
    ],
  },
}),

Key behaviors

  • Side effects only: afterChange hooks do not return a value. They cannot modify the saved document. Use collection afterChange for auto-patching.
  • Runs after db write: the database insert/patch has already occurred. The doc includes the _id and all saved fields.
  • Runs before collection afterChange: field afterChange hooks execute before collection-level afterChange hooks.
  • Only runs for hooked fields: unlike beforeChange, afterChange only runs on fields that have the hook defined.

Common use cases

Send notification on status change:

status: f.select({
  options: ["draft", "published"],
  hooks: {
    afterChange: [({ value, previousValue, doc }) => {
      if (value === "published" && previousValue !== "published") {
        console.log(`Document ${doc._id} was published`);
      }
    }],
  },
}),

Log field value changes:

price: f.number({
  hooks: {
    afterChange: [({ value, previousValue, fieldName, doc }) => {
      if (previousValue !== undefined && previousValue !== value) {
        console.log(`${fieldName} changed from ${previousValue} to ${value} on ${doc._id}`);
      }
    }],
  },
}),

Field beforeDuplicate

Transforms a copied field value during document duplication. Runs after the source document is cloned and system fields are stripped, but before the cloned data enters the normal create pipeline (beforeChange -> afterChange).

hooks: {
  beforeDuplicate: [
    ({ value, siblingData, fieldName, ctx, collection, collectionSlug, context }) => {
      // value: the copied field value from the source document
      // siblingData: the full cloned payload (mutable reference)
      // fieldName: the name of this field
      // ctx: Convex mutation context
      // collection: admin collection metadata
      // collectionSlug: the collection's slug

      // Return a transformed value, or void to keep unchanged
      return transformedValue;
    },
  ],
}

Key behaviors

  • Runs before beforeChange: beforeDuplicate hooks execute first, then the transformed values flow through the normal beforeChange -> collection beforeChange -> insert -> afterChange pipeline.
  • Only fires during duplication: this hook does not run on normal creates or updates. It only fires when a document is duplicated via the admin UI or the duplicateDocumentForCollection mutation.
  • Status is already reset: content collection documents have their status reset to "draft" before beforeDuplicate hooks run.
  • System fields are stripped: _id, _creationTime, updatedAt, createdAt, and updatedBy are removed before hooks run.

Common use cases

Clear unique slugs:

slug: f.slug({
  sourceField: "title",
  hooks: {
    beforeDuplicate: [({ value }) => {
      if (typeof value === "string") {
        return `${value}-copy`;
      }
    }],
  },
}),

Append " (Copy)" to titles:

title: f.text({
  required: true,
  hooks: {
    beforeDuplicate: [({ value }) => {
      if (typeof value === "string") {
        return `${value} (Copy)`;
      }
    }],
  },
}),

Reset a field on duplication:

publishedAt: f.datetime({
  hooks: {
    beforeDuplicate: [() => undefined],
  },
}),

Field hooks vs collection hooks

Use field hooks for per-value transformations. Use collection hooks for cross-field logic.

ScenarioUse
Trim whitespace from a text fieldField beforeChange
Normalize an email to lowercaseField beforeChange
Compute salePrice from price and discountPercentCollection beforeChange
Build address from address1, city, state, zipCollection beforeChange
Mask a value for displayField afterRead
Send notification when a status field changesField afterChange
Generate a reference code using the document _idCollection afterChange
Inject a computed flag before locale mergeCollection beforeRead
Add a cross-field display labelCollection afterRead
Prevent deletion of published documentsCollection beforeDelete
Clear unique slugs when duplicatingField beforeDuplicate
Transform copied values during duplicationField beforeDuplicate

Shared hook context

Every hook receives a context property -- a mutable Record<string, unknown> object that persists across all hooks within a single mutation or query invocation. Use it to share data between field hooks and collection hooks without redundant computation.

hooks: {
  beforeChange: [
    ({ data, context }) => {
      // Compute something expensive once
      context.geocoded = geocode(data.address);
    },
  ],
}

// In a field hook on the same collection:
city: f.text({
  hooks: {
    beforeChange: [({ context }) => {
      // Access data computed by the collection hook
      return (context.geocoded as any)?.city;
    }],
  },
}),

A fresh context object is created at the start of each mutation or query. It is not shared across different mutations or requests.

Scope

The hook context is scoped to a single Convex function invocation. Unlike PayloadCMS's req.context (which spans an HTTP request), the Vextro hook context covers one mutation or query.


Hook arguments reference

Collection hook arguments

All collection mutation hooks receive:

PropertyTypeDescription
ctxGenericMutationCtxFull Convex mutation context -- query, mutate, schedule
collectionAdminCollectionThe collection's admin metadata record
collectionSlugstringThe collection's slug identifier
contextHookContextShared mutable context object for the current invocation

Collection read hooks (beforeRead, afterRead) receive the same properties but with GenericQueryCtx (read-only) instead of GenericMutationCtx.

Field hook arguments

All field hooks receive:

PropertyTypeDescription
ctxGenericMutationCtx or GenericQueryCtxMutation context for beforeChange/afterChange, query context for afterRead
fieldNamestringThe name of the field being processed
collectionAdminCollectionThe collection's admin metadata record
collectionSlugstringThe collection's slug identifier
contextHookContextShared mutable context object for the current invocation

Execution order

Hooks run at specific points in the pipeline. Field hooks run before collection hooks for mutations.

Create/Update pipeline

requireAdminWrite
  -> resolve collection + table
  -> strip restricted fields (permissions)
  -> validate collection filter
  -> scope enforcement
  -> set defaults (status, updatedAt)
  -> field-level beforeChange hooks (per-field)
  -> collection-level beforeChange hooks (cross-field)
  -> ctx.db.insert / ctx.db.patch
  -> field-level afterChange hooks (side effects)
  -> collection-level afterChange hooks (auto-patch if returned)
  -> version snapshot
  -> sync block usage
  -> audit log
  -> webhooks
  -> cache invalidation

Duplicate pipeline

When a document is duplicated via the admin UI or the duplicateDocumentForCollection mutation:

requireAdminWrite
  -> resolve collection + table
  -> read source document
  -> strip system fields (_id, _creationTime, updatedAt, createdAt, updatedBy)
  -> reset status to "draft" (content collections)
  -> field-level beforeDuplicate hooks (per-field value transform)
  -> field-level beforeChange hooks (as "create" operation)
  -> collection-level beforeChange hooks
  -> ctx.db.insert
  -> field-level afterChange hooks
  -> collection-level afterChange hooks (auto-patch if returned)
  -> sync block usage
  -> audit log (action: "document.duplicate")
  -> webhooks
  -> cache invalidation

Delete pipeline

Field hooks do not apply to deletes:

requireAdminWrite
  -> resolve collection + table
  -> scope enforcement
  -> beforeDelete hooks
  -> version snapshot
  -> validate collection filter
  -> sync block usage
  -> file cleanup
  -> ctx.db.delete
  -> afterDelete hooks
  -> audit log
  -> webhooks
  -> cache invalidation

Read pipeline

requireAdminRead
  -> resolve collection + table
  -> load document(s) from database
  -> collection-level beforeRead hooks (raw doc, before locale merge)
  -> locale merge (if applicable)
  -> field-level afterRead hooks (per-field)
  -> collection-level afterRead hooks (cross-field, final transform)
  -> return to client

Multiple hooks

When multiple hooks are defined for the same event, they execute in array order:

hooks: {
  beforeChange: [
    // First: normalize data
    ({ data }) => {
      return { ...data, name: String(data.name).trim() };
    },
    // Second: compute derived field (receives normalized data)
    ({ data }) => {
      return { ...data, nameLength: String(data.name).length };
    },
  ],
}

Each hook receives the output of the previous one. If a hook returns void (no return), the data passes through unchanged. This applies to both collection and field hooks.

Async hooks

Hooks can be async. They block execution until they complete.

beforeChange: [
  async ({ data, ctx }) => {
    // Query another table
    const existing = await ctx.db
      .query("products")
      .withIndex("by_sku", (q) => q.eq("sku", data.sku as string))
      .unique();
    if (existing) {
      throw new Error(`SKU "${data.sku}" already exists`);
    }
  },
],

TypeScript types

All hook types are exported from the vextro package:

// Shared context type
import type { HookContext } from "vextro";

// Collection hook types
import type {
  CollectionHooks,
  CollectionHookContext,
  BeforeChangeHook,
  AfterChangeHook,
  BeforeDeleteHook,
  AfterDeleteHook,
  BeforeReadHook,
  AfterReadHook,
} from "vextro";

// Field hook types
import type {
  FieldHooks,
  FieldHookContext,
  FieldBeforeChangeHook,
  FieldAfterReadHook,
  FieldAfterChangeHook,
  FieldBeforeDuplicateHook,
} from "vextro";

// Global hook types
import type {
  GlobalHooks,
  GlobalBeforeChangeHook,
  GlobalAfterChangeHook,
  GlobalBeforeReadHook,
  GlobalAfterReadHook,
} from "vextro";

All hook types accept a generic DataModel parameter for type-safe ctx access. When defining hooks inline on a collection, field, or global, any is used automatically.

Global hooks

Globals support the same hook pattern as collections, minus delete hooks (globals are singletons and cannot be deleted). Global hooks are defined on the global definition using defineVextroGlobal.

Available global hooks

HookFiresCan modify data?
beforeChangeBefore global update writeYes -- return modified data
afterChangeAfter global update writeYes -- return a patch (triggers second write)
beforeReadBefore locale merge and field hooksYes -- return modified doc
afterReadAfter field hooks and locale mergeYes -- return modified doc

Defining global hooks

import { defineVextroGlobal, f } from "vextro";

export const siteSettings = defineVextroGlobal({
  slug: "site-settings",
  label: "Site Settings",
  tableName: "site_settings",
  fields: {
    siteName: f.text({ required: true }),
    maintenanceMode: f.checkbox(),
    updatedAt: f.number({ readOnly: true }),
  },
  hooks: {
    beforeChange: [
      ({ data }) => {
        return { ...data, updatedAt: Date.now() };
      },
    ],
    afterChange: [
      ({ doc, previousDoc }) => {
        if (doc.maintenanceMode && !previousDoc?.maintenanceMode) {
          console.log("Maintenance mode enabled");
        }
      },
    ],
    afterRead: [
      ({ doc }) => {
        return { ...doc, isLive: !doc.maintenanceMode };
      },
    ],
  },
});

Global hook arguments

Global hooks receive the same context (shared hook context) as collection hooks, but with global and globalSlug instead of collection and collectionSlug:

PropertyTypeDescription
ctxGenericMutationCtx or GenericQueryCtxMutation context for change hooks, query context for read hooks
globalAdminGlobalThe global's admin metadata record
globalSlugstringThe global's slug identifier
contextHookContextShared mutable context object

Global field hooks

Field-level hooks (beforeChange, afterRead, afterChange) also work on global fields, using the same syntax as collection fields:

export const siteSettings = defineVextroGlobal({
  slug: "site-settings",
  label: "Site Settings",
  tableName: "site_settings",
  fields: {
    siteName: f.text({
      required: true,
      hooks: {
        beforeChange: [({ value }) => String(value ?? "").trim()],
      },
    }),
    tagline: f.text({
      hooks: {
        afterRead: [({ value }) => {
          if (typeof value === "string") {
            return value.replace(/\n/g, " ");
          }
        }],
      },
    }),
  },
});

Global execution order

Global update:
  -> field-level beforeChange hooks (per-field)
  -> global-level beforeChange hooks
  -> ctx.db.patch / ctx.db.insert
  -> field-level afterChange hooks (side effects)
  -> global-level afterChange hooks (auto-patch if returned)

Global read:
  -> global-level beforeRead hooks (raw doc)
  -> field-level afterRead hooks (per-field)
  -> global-level afterRead hooks (final transform)
  -> return to client

Bulk operations

Bulk mutations (bulkCreateDocuments, bulkUpdateStatus, bulkDeleteDocuments) do not trigger hooks -- neither collection hooks nor field hooks. This is intentional:

  • Bulk operations process many documents in a single mutation
  • Running hooks per-document would add significant overhead
  • This matches PayloadCMS behavior where bulk operations bypass hooks

If you need hook logic to run during bulk operations, process documents individually through the standard create/update/delete mutations.

Error handling

  • If a beforeChange hook (collection, field, or global) throws, the entire mutation aborts. No database write occurs.
  • If an afterChange or afterDelete hook throws, the error propagates and the mutation fails, but the initial database write has already occurred.
  • beforeRead and afterRead hooks run in query context. If one throws, the query fails and no data is returned.
  • Field afterChange hooks run after the database write but before collection afterChange hooks. If a field afterChange throws, collection afterChange hooks do not run.
  • Audit logging and webhook dispatch happen after hooks, so a hook error prevents those side effects.
  • Global hooks follow the same error semantics as their collection counterparts.
Previous
Hierarchy & Nested Categories
Next
Audit Log