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 — no row is written. What the client sees
depends on which error you throw.
HookError — the reason belongs to the editor
Throw HookError when the hook is enforcing a rule a person can act on. It becomes a 400
VALIDATION_ERROR carrying your message, which the admin shows instead of a generic failure:
import { HookError } from "@cms/config";
beforeDelete: async (id, context) => {
const hasChildren = await checkChildren(id, context.db);
if (hasChildren) {
throw new HookError("Cannot delete: entity has children");
}
},
Pass details to scope messages to specific fields. The admin renders each one inline on that
field rather than as a toast, so the editor sees the problem where they can fix it:
beforePublish: async (data) => {
if (hasGap(data.days)) {
throw new HookError("Days can't have gaps", [
{ field: "days", message: "Day 3 is empty but later days are filled" },
]);
}
return data;
},
Each field must be a field name on the entity being written; unknown names still surface, just not
attached to an input.
A plain Error — something broke
Anything else a hook throws is treated as an unhandled failure: the write aborts, the error is
logged in full, and the client gets a generic 500. This is deliberate — a hook that dies on a bad
query or an unreachable service must not put its raw message into an API response. If you want the
message seen, that’s what HookError is for.
Throwing an error with your own status (e.g. a conflict) is not supported from a hook; use
HookError and let the engine pick the status.
Previous
Permissions
Next
Drafts and publishing