Hooks
Lifecycle callbacks for business logic on create, update, and delete.
Hooks let you run code before or after CRUD operations. Define them on the entity:
import { entity, text, slug } from "@cms/config";
const posts = entity("posts", {
hooks: {
beforeCreate: async (data, context) => {
data.slug = slugify(data.title);
return data;
},
afterCreate: async (document, context) => {
await notify(document);
},
beforeUpdate: async (data, existing, context) => {
// Derive a denormalised counter / flag — anything you can compute from
// the incoming patch and the prior row. `publishedAt` itself is engine-
// managed when `versions: true` (set by the publish path); user code
// should not write to it.
if (data.status === "archived" && !existing.archivedAt) {
data.archivedAt = new Date().toISOString();
}
return data;
},
afterUpdate: async (document, context) => {},
beforeDelete: async (id, context) => {},
afterDelete: async (id, context) => {},
},
fields: [
text("title", { required: true }),
slug("slug", { required: true }),
],
});
Hook signatures
Before hooks
Before hooks receive the data being written and can modify it or throw to abort:
// beforeCreate — receives the incoming data
beforeCreate: (data: Record<string, unknown>, context: HookContext) =>
Promise<Record<string, unknown>> | Record<string, unknown>;
// beforeUpdate — receives the incoming data AND the existing document
beforeUpdate: (
data: Record<string, unknown>,
existing: Record<string, unknown>,
context: HookContext
) => Promise<Record<string, unknown>> | Record<string, unknown>;
// beforeDelete — receives the record ID, throw to abort
beforeDelete: (id: string, context: HookContext) =>
Promise<void> | void;
Return the (potentially modified) data from beforeCreate and beforeUpdate. The returned value is what gets written.
After hooks
After hooks run after the operation completes. They’re fire-and-forget — errors are logged but don’t affect the response:
afterCreate: (document: Record<string, unknown>, context: HookContext) =>
Promise<void> | void;
afterUpdate: (document: Record<string, unknown>, context: HookContext) =>
Promise<void> | void;
afterDelete: (id: string, context: HookContext) =>
Promise<void> | void;
Hook context
Every hook receives a context object:
type HookContext = {
user: { id: string; email: string; role: string } | null;
role: string;
db: PostgresJsDatabase; // Drizzle instance for custom queries
entity: string; // entity name
};
The db instance gives you full access to Drizzle ORM for running custom queries inside hooks:
beforeCreate: async (data, { db }) => {
const [existing] = await db
.select()
.from(someTable)
.where(eq(someTable.slug, data.slug));
if (existing) {
throw new Error("Slug already exists");
}
return data;
},
Field-level hooks
Hooks can also live on an individual field, declared in the field’s options. They
fire for that field wherever it appears in the document — including fields nested
inside object, array, and union (block) structures. The block definition
itself declares the behaviour, so a block stays self-contained: add it to any
entity and it works.
import { relation } from "@cms/config";
relation("project", {
to: "project_page",
hooks: {
// Always point this block's relation at the document it lives in.
beforeRead: ({ document }) => ({ id: document.id, _entity: "project_page" }),
},
});
The four field hooks
| Hook | Phase | Return | On error |
|---|---|---|---|
beforeRead | After load, before relation resolution | New value for the field | Request fails |
afterRead | After relation resolution | New value for the field | Request fails |
beforeWrite | After Zod validation, before write | New value (re-validated against the field schema) | Request fails |
afterWrite | After the write commits | — (ignored) | Logged, never thrown |
text("code", {
hooks: {
// Normalise on the way in. The return value is re-validated against the
// field's schema, so a buggy hook is caught at write time.
beforeWrite: ({ value, operation, existing }) => value?.toUpperCase(),
// Fire-and-forget side effect with the written value + post-write document.
afterWrite: ({ value, document, operation }) => audit(document.id, value),
// Reshape on the way out (e.g. redact based on the reader's role).
afterRead: ({ value, user }) => (user?.role === "admin" ? value : "***"),
},
});
Self-referencing blocks
The headline use case: a block inside a page needs page-level data, but the
rendering layer should stay a uniform block → component loop. A beforeRead
hook on a relation field sets it to the parent document’s id; because it runs
before resolution, the standard ?resolve machinery then populates it:
const page = entity("project_page", {
fields: [
text("title", { required: true }),
object("attributes", { fields: [text("location"), text("awards")] }),
array("blocks", {
of: union("block", {
of: [
object("project_hero", {
fields: [
text("headline"),
relation("project", {
to: "project_page",
hooks: {
beforeRead: ({ document }) => ({
id: document.id,
_entity: "project_page",
}),
},
}),
],
}),
],
}),
}),
],
});
GET /api/project_page/:id?resolve[blocks.project]=*
The project_hero block comes back with project populated by the parent page
— no data duplication, no client-side fetch, no special-casing in the renderer.
Resolution stays client-controlled: omit ?resolve and you get the bare
{ id, _entity } reference. Recursion is bounded by the resolver’s depth limit,
so the parent’s own blocks are not re-expanded.
Context shapes
// beforeRead / afterRead — `document` is normalised but pre-resolution (its
// relations are { id, _entity } stubs). No `db` — keep field hooks pure.
type FieldReadContext<TValue> = {
value: TValue;
document: Record<string, unknown>;
field: AnyFieldBase;
user: { id: string; email: string; role: string } | null;
entityName: string;
};
// beforeWrite — `data` is the full submitted document; `existing` is the prior
// canonical row (undefined on create).
type FieldWriteContext<TValue> = {
value: TValue;
data: Record<string, unknown>;
operation: "create" | "update";
existing: Record<string, unknown> | undefined;
user: { id: string; email: string; role: string } | null;
entityName: string;
};
// afterWrite — `document` is the post-write canonical row.
type FieldAfterWriteContext<TValue> = {
value: TValue;
document: Record<string, unknown>;
operation: "create" | "update";
user: { id: string; email: string; role: string } | null;
entityName: string;
};
Container vs. element granularity
A hook on a container field receives the whole container; a hook on a field
inside a container fires once per element. A beforeRead on an array field
sees the full array (sort/filter it); a beforeRead on a field nested inside the
array fires once per item.
Built-in hooks
The _user entity has automatic password hashing via built-in hooks. The API accepts plaintext passwords — they’re hashed with bcrypt before storage. Don’t send pre-hashed values.
Throwing to abort
Any before* hook can throw to abort the operation. The error message is returned to the client:
beforeDelete: async (id, context) => {
const hasChildren = await checkChildren(id, context.db);
if (hasChildren) {
throw new Error("Cannot delete: entity has children");
}
},
Previous
Permissions
Next
Drafts and publishing