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:
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:
| Method | Path | Description | Response |
|---|---|---|---|
POST | /users/api | Create a new record | { _id: string } |
GET | /users/api | List records | { results: [...] } |
GET | /users/api/:id | Retrieve one record | { results: [...] } - the same array shape, with 0 or 1 entry |
GET | /users/api/count | Count records | { count: number } |
PATCH | /users/api/:id | Update a record | 200 OK, empty body |
DELETE | /users/api/:id | Delete a record | 200 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
| Field | Type | Description |
|---|---|---|
router | Router | The router instance to attach routes to |
schema | ZodObject | The full document schema (used for response validation) |
model | Collection | The MongoDB collection model |
insertSchema | ZodObject | The input schema (used for create validation) |
updateSchema | ZodObject (optional) | A narrowed schema for update operations. Defaults to a partial of insertSchema. |
Options
| Field | Type | Description |
|---|---|---|
disable | object | Disable specific endpoints by setting them to true (e.g. { del: true }) |
projection | object (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) => object | An async function that returns extra fields merged into writes and used to scope reads |
hooks | object | Lifecycle 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:
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:
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.
| Hook | Receives | Returning 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.
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
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 generationWant 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.