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

HookPhaseReturnOn error
beforeReadAfter load, before relation resolutionNew value for the fieldRequest fails
afterReadAfter relation resolutionNew value for the fieldRequest fails
beforeWriteAfter Zod validation, before writeNew value (re-validated against the field schema)Request fails
afterWriteAfter 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