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
| Hook | Fires | Can modify data? | Can abort? |
|---|---|---|---|
beforeChange | Before create/update write | Yes -- return modified data | Yes -- throw an error |
afterChange | After create/update write | Yes -- return a patch (triggers second write) | No |
beforeDelete | Before delete | No | Yes -- throw an error |
afterDelete | After delete | No | No |
beforeRead | Before locale merge and field hooks | Yes -- return modified doc | No |
afterRead | After field hooks and locale merge | Yes -- return modified doc | No |
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:
_idand_creationTimeare automatically stripped from the returned object to prevent accidental overwrites. - Updates receive the patch: for update operations,
datacontains only the changed fields (the patch), not the full document. ReadoriginalDocif 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
_idor_creationTime), usebeforeChangeinstead 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:
beforeReadhooks run in Convex query functions. Thectxis aGenericQueryCtxwith 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), andgetCollectionPageDataPaginated(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
afterReadhooks 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
| Hook | Fires | Context | Purpose |
|---|---|---|---|
beforeChange | Before create/update write | Mutation (read-write) | Normalize, transform, or compute a field value before it is stored |
afterRead | When a document is read | Query (read-only) | Transform a field value for display without changing the stored data |
afterChange | After create/update write | Mutation (read-write) | React to a saved field value with side effects |
beforeDuplicate | During document duplication | Mutation (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,
valuecomes fromoriginalDoc. This lets hooks compute derived values even when their source field was not explicitly changed. - Runs before collection hooks: field
beforeChangehooks execute before collectionbeforeChangehooks, 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
undefinedorvoid, 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:
afterReadhooks run in Convex query functions, not mutations. Thectxis aGenericQueryCtxwith no write access. - Applied in all query paths: hooks run in
getDocumentForCollection(single document),getCollectionPageData(list view), andgetCollectionPageDataPaginated(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:
afterChangehooks do not return a value. They cannot modify the saved document. Use collectionafterChangefor auto-patching. - Runs after db write: the database insert/patch has already occurred. The
docincludes the_idand all saved fields. - Runs before collection afterChange: field
afterChangehooks execute before collection-levelafterChangehooks. - Only runs for hooked fields: unlike
beforeChange,afterChangeonly 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:
beforeDuplicatehooks execute first, then the transformed values flow through the normalbeforeChange-> collectionbeforeChange-> insert ->afterChangepipeline. - 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
duplicateDocumentForCollectionmutation. - Status is already reset: content collection documents have their status reset to
"draft"beforebeforeDuplicatehooks run. - System fields are stripped:
_id,_creationTime,updatedAt,createdAt, andupdatedByare 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.
| Scenario | Use |
|---|---|
| Trim whitespace from a text field | Field beforeChange |
| Normalize an email to lowercase | Field beforeChange |
Compute salePrice from price and discountPercent | Collection beforeChange |
Build address from address1, city, state, zip | Collection beforeChange |
| Mask a value for display | Field afterRead |
| Send notification when a status field changes | Field afterChange |
Generate a reference code using the document _id | Collection afterChange |
| Inject a computed flag before locale merge | Collection beforeRead |
| Add a cross-field display label | Collection afterRead |
| Prevent deletion of published documents | Collection beforeDelete |
| Clear unique slugs when duplicating | Field beforeDuplicate |
| Transform copied values during duplication | Field 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:
| Property | Type | Description |
|---|---|---|
ctx | GenericMutationCtx | Full Convex mutation context -- query, mutate, schedule |
collection | AdminCollection | The collection's admin metadata record |
collectionSlug | string | The collection's slug identifier |
context | HookContext | Shared 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:
| Property | Type | Description |
|---|---|---|
ctx | GenericMutationCtx or GenericQueryCtx | Mutation context for beforeChange/afterChange, query context for afterRead |
fieldName | string | The name of the field being processed |
collection | AdminCollection | The collection's admin metadata record |
collectionSlug | string | The collection's slug identifier |
context | HookContext | Shared 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
| Hook | Fires | Can modify data? |
|---|---|---|
beforeChange | Before global update write | Yes -- return modified data |
afterChange | After global update write | Yes -- return a patch (triggers second write) |
beforeRead | Before locale merge and field hooks | Yes -- return modified doc |
afterRead | After field hooks and locale merge | Yes -- 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:
| Property | Type | Description |
|---|---|---|
ctx | GenericMutationCtx or GenericQueryCtx | Mutation context for change hooks, query context for read hooks |
global | AdminGlobal | The global's admin metadata record |
globalSlug | string | The global's slug identifier |
context | HookContext | Shared 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
beforeChangehook (collection, field, or global) throws, the entire mutation aborts. No database write occurs. - If an
afterChangeorafterDeletehook throws, the error propagates and the mutation fails, but the initial database write has already occurred. beforeReadandafterReadhooks run in query context. If one throws, the query fails and no data is returned.- Field
afterChangehooks run after the database write but before collectionafterChangehooks. If a fieldafterChangethrows, collectionafterChangehooks 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.