Hooks
Hooks run at lifecycle stages around collection and global operations. Use them to validate, transform, audit, or short-circuit writes. This page is the reference for the hook map, the context object, and the execution model. For how to register hooks from a plugin, see Plugins.
Hook stages
There are eight stages, run in this order for each operation:
| Stage | Runs before | Common use |
|---|---|---|
beforeOperation | Any operation begins | Auth checks, logging, early aborts |
beforeValidate | Validation runs | Mutate input before schema validation |
beforeSave | The write hits the database | Last-chance transformation |
afterSave | The write completes | Side effects, cache invalidation |
afterPublish | A publish operation completes | Syndication, webhook fan-out |
afterDelete | A delete completes | Cleanup, audit logging |
afterRead | A read completes | Shape output, attach metadata |
afterError | An operation throws | Error logging, alerting |
The runtime calls hooks in registration order: collection hooks first, then plugin hooks, then global hooks. Hooks registered at the same stage run sequentially in the order they were added.
HookContext
Every hook receives a HookContext object with the operation metadata and the document data.
type HookContext = { type: "collection" | "global"; key: string; // collection or global key stage: HookStageName; // the current stage operation: "create" | "update" | "delete" | "read" | "publish"; data: Record<string, unknown>; // the incoming document data original?: Record<string, unknown> | null; // prior data on update/delete extensions?: Record<string, unknown>; logger?: unknown; meta?: { requestId?: string; auth?: unknown; [key: string]: unknown; };};For update and delete operations, original holds the document state before the change. For create and read, original is null.
HookFunction and HookResult
A hook is a function that takes the context and returns a result. The return type controls what happens next.
type HookFunction = (context: HookContext) => HookResult | Promise<HookResult>;type HookResult = void | HookAbortResult | HookMutationResult;void
Return nothing (or undefined) to let the operation continue unchanged. This is what most read-only hooks do.
Mutate (HookMutationResult)
Return { data, meta } to replace the document data and merge metadata before the operation continues. The runtime applies your data to the context before the next stage runs.
type HookMutationResult = { data?: Record<string, unknown>; meta?: Record<string, unknown>;};Use this in beforeValidate or beforeSave to transform the document before it reaches validation or the database.
Abort (HookAbortResult)
Return { abort: true, reason?, data?, meta? } to stop the operation. The runtime rejects the request with an error. The optional reason becomes the error message.
type HookAbortResult = { abort: true; reason?: string; data?: Record<string, unknown>; meta?: Record<string, unknown>;};Use this in beforeOperation or beforeValidate to reject a write that passes schema validation but violates a business rule.
Registering hooks
Hooks can be registered in three places:
- On a collection or global definition, under the
hookskey. - On a plugin definition, under the
hookskey. - Dynamically in a plugin’s
setupfunction, usingregisterHook(stage, fn).
defineCollection({ key: "posts", fields: [/* ... */], hooks: { beforeValidate: [ (ctx) => { // Auto-generate a slug from the title if none is set if (!ctx.data.slug && ctx.data.title) { return { data: { ...ctx.data, slug: slugify(ctx.data.title) } }; } }, ], afterSave: [ async (ctx) => { // Send to an analytics pipeline await analytics.track("post_saved", { id: ctx.data.id }); }, ], },});The runtime merges hook maps from collections, globals, and plugins. When multiple hooks target the same stage, they run in order: the collection’s hooks, then each plugin’s hooks in plugin registration order.
Execution model
The runner iterates hooks at a stage sequentially. Each hook receives the context as updated by the previous hook. If a hook returns a mutation, the mutated data carries forward to the next hook. If a hook aborts, the runner stops and the operation fails.
After all mutation stages complete (beforeOperation, beforeValidate, beforeSave), the runtime performs the write. Then afterSave, afterPublish, or afterDelete runs. afterRead runs on read operations. afterError runs when any operation throws, giving you a place to log or react.
The hook runner is an advanced surface. The hook registration shape (hooks: { stage: [fn] }) and HookContext are stable for application and plugin authors. The runner internals in @timbl/core are internal.
See also
- Plugins: how plugins register hooks and the full plugin API
- Configuration: registering hooks on collections and globals
- API Tiers: which hook surfaces are stable vs. advanced