Create CRUD

Instantly scaffold a fully validated CRUD API for any model with a single function call.

createCRUD is one of Thunder's most powerful built-in utilities. It allows you to instantly generate a fully functional, validated CRUD (Create, Read, Update, Delete) API for any model - without writing a single individual route handler.

Under the hood, createCRUD wires up all standard operations against your Zod schema and MongoDB model automatically, including a count endpoint.


Basic Usage

Use the crud snippet or write it manually inside a Router:

routes/users.ts
import { Router } from "@/core/http/router.ts";
import { createCRUD, $objectId } from "@/core/utils/createCRUD.ts";
import { userModel, $user, $userInput } from "@/schemas/user.ts";
import z from "zod";

export default new Router("/api", function users(router) {
  createCRUD({
    router,
    schema: $user,
    model: userModel,
    insertSchema: $userInput,
  });
}).group("Users");

This single call produces the following endpoints automatically:

MethodPathDescriptionResponse
POST/users/apiCreate a new record{ _id: string }
GET/users/apiList records{ results: [...] }
GET/users/api/:idRetrieve one record{ results: [...] } - the same array shape, with 0 or 1 entry
GET/users/api/countCount records{ count: number }
PATCH/users/api/:idUpdate a record200 OK, empty body
DELETE/users/api/:idDelete a record200 OK, empty body

GET /:id is the list route with an optional parameter, so it returns an array, not a bare object - and the pagination query still applies to it. Read results[0], and handle the empty case.

Because Thunder uses file-based routing, the file name itself (e.g. users.ts) acts as the route prefix. The router base path ("/api" above) is appended after it, producing endpoints like /users/api. The router's function name (users) is only a scope label for metadata, permissions and OpenAPI generation - it is not part of the URL. Read more in File-Based Routing.

createCRUD also writes schema, insertSchema and updateSchema onto router.metadata.crud as JSON Schema. That is what drives SDK and OpenAPI generation - and what Thunder UI reads to build its screens.


Configuration Options

createCRUD accepts two arguments: a required config object and an optional options object.

Required Config

FieldTypeDescription
routerRouterThe router instance to attach routes to
schemaZodObjectThe full document schema (used for response validation)
modelCollectionThe MongoDB collection model
insertSchemaZodObjectThe input schema (used for create validation)
updateSchemaZodObject (optional)A narrowed schema for update operations. Defaults to a partial of insertSchema.

Options

FieldTypeDescription
disableobjectDisable specific endpoints by setting them to true (e.g. { del: true })
projectionobject (or array)A fixed $project stage applied on GET, before the client's own projection. Use it to strip secrets: { password: 0 }
isolationFields(req, action) => objectAn async function that returns extra fields merged into writes and used to scope reads
hooksobjectLifecycle hooks - see Lifecycle Hooks

projection is applied server-side and cannot be overridden by the caller, so it is the right place to hide fields like password. A client-supplied project query parameter is applied afterwards.


Disabling Specific Methods

If you need custom logic for a particular endpoint, disable it from createCRUD and define it manually afterward:

routes/users.ts
export default new Router("/api", function users(router) {
  createCRUD(
    {
      router,
      schema: $user,
      model: userModel,
      insertSchema: $userInput,
    },
    {
      disable: { del: true }, // Disable the DELETE endpoint
    }
  );
}).group("Users");

Refer to the Routes documentation for full details on writing individual route handlers.


Isolation Fields

isolationFields automatically injects fields into every write operation that are not part of the user-facing input schema, and scopes read/update/delete queries to those same fields. This is particularly useful for multi-tenancy, user scoping, or ownership tracking.

The function is async and receives the request plus the current action ("create", "update", "del", "get", "count"), so you can return different fields per action:

routes/users.ts
export default new Router("/api", function users(router) {
  createCRUD(
    {
      router,
      schema: $user,
      model: userModel,
      insertSchema: $userInput,
      updateSchema: $userInput.pick({ name: true }),
    },
    {
      isolationFields: async (req, action) => {
        // On create, persist both org and user so they can be matched later.
        if (action === "create") {
          return {
            orgId: req.headers.get("X-Org-Id"),
            userId: req.headers.get("X-User-Id"),
          };
        }

        // Allow only the creator to edit or delete their own records.
        if (["update", "del"].includes(action)) {
          return {
            orgId: req.headers.get("X-Org-Id"),
            userId: req.headers.get("X-User-Id"),
          };
        }

        // For reads, scope by organization only.
        return {
          orgId: req.headers.get("X-Org-Id"),
        };
      },
    }
  );
}).group("Users");

In a real application the userId would usually come from the authenticated session (see thunder-core); the headers above are used for simplicity. Omit isolationFields entirely if you do not need per-user data isolation.


Lifecycle Hooks

Use hooks to run logic at key points in the CRUD lifecycle - for example, stamping records or triggering side effects.

HookReceivesReturning a value
beforeCreate{ data, session }replaces the data that gets inserted
afterCreate{ results, data, session }-
beforeUpdate{ _id, data, session }replaces the $set payload
afterUpdate{ _id, results, data, session }-
beforeDel{ _id, session }-
afterDel{ _id, results, session }-

Every hook also receives session, the MongoDB session for the surrounding transaction (see Gotchas).

In afterCreate, the inserted id is on results, not on data - data is the parsed request body. So it is results._id, not data._id.

routes/users.ts
createCRUD(
  {
    router,
    schema: $user,
    model: userModel,
    insertSchema: $userInput,
  },
  {
    hooks: {
      beforeCreate: ({ data, session }) => ({ ...data, createdBy: "system" }),
      afterCreate: ({ results, data, session }) => console.log("Created:", results._id),
    },
  }
);

Gotchas

Four behaviours that surprise people. None of them are bugs - but all of them will cost you an afternoon if you meet them by accident.

Registering any hook turns the operation into a transaction

If any hook exists for create / update / delete, that operation runs inside mongodb.withSession(_ => _.withTransaction(...)), which requires a MongoDB replica set. With no hooks it stays a plain single operation.

A standalone mongod has no transactions. If adding your first hook suddenly breaks writes, this is why - run a replica set (even a single-node one) in development.

A zero-match PATCH / DELETE throws - unless an after-hook exists

Normally, updating or deleting a record that matches nothing throws (No record updated! / No record deleted!). Register an afterUpdate or afterDel hook and that check goes away - a no-op write silently returns 200.

So if you register one, check it yourself:

hooks: {
  afterUpdate: ({ results }) => {
    if (results.modifiedCount === 0) throw Response.notFound("Nothing to update");
  },
},

updatedAt is always written on PATCH

Whether or not your schema declares it.

GET /:id returns an array

See the endpoint table above.


Filtering, Sorting & Pagination

Every generated GET accepts a rich query for filtering, sorting, projection and paging, and /count accepts the filter part of it. That format - and the helpers that let you reuse it outside a CRUD route - has its own page:

See Filtering & Pagination for the query parameters, the client filter format, and normalizeFilters / testFilters / paginatedAggregation.


Complete Example

routes/users.ts
import { Router } from "@/core/http/router.ts";
import { createCRUD, $objectId } from "@/core/utils/createCRUD.ts";
import { userModel, $user, $userInput } from "@/schemas/user.ts";
import z from "zod";

export default new Router("/api", function users(router) {
  createCRUD(
    {
      router,
      schema: $user,
      model: userModel,
      insertSchema: $userInput,
      updateSchema: $userInput.pick({ name: true }),
    },
    {
      disable: { del: true },
      isolationFields: async (req, action) => {
        if (action === "create") {
          return {
            orgId: req.headers.get("X-Org-Id"),
            userId: req.headers.get("X-User-Id"),
          };
        }
        return { orgId: req.headers.get("X-Org-Id") };
      },
      hooks: {
        beforeCreate: ({ data, session }) => ({ ...data, createdBy: "system" }),
        afterCreate: ({ results, data, session }) => console.log("Created:", results._id),
      },
    }
  );
}).group("Users"); // Groups endpoints for cleaner OpenAPI spec generation

Want to see all of this combined in a real, multi-tenant project - including thunder-core auth, an afterCreate notification, and a disabled route replaced with custom logic? Follow the Posts CMS example. For the same thing carried all the way to a generated admin dashboard, see Rental Desk.


On this page