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 (record, context) => {
await notify(record);
},
beforeUpdate: async (data, existing, context) => {
if (data.status === "published" && !existing.publishedAt) {
data.publishedAt = new Date().toISOString();
}
return data;
},
afterUpdate: async (record, 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 record
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: (record: Record<string, unknown>, context: HookContext) =>
Promise<void> | void;
afterUpdate: (record: 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
collection: 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;
},
Built-in hooks
The _users 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
Internationalization