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 |
|---|---|---|
POST | /users/api | Create a new record |
GET | /users/api | List all records |
GET | /users/api/:id | Retrieve a single record |
GET | /users/api/count | Count records |
PATCH | /users/api/:id | Update an existing record |
DELETE | /users/api/:id | Delete a record |
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. Read more in the File-Based Routing documentation.
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 }) |
isolationFields | (req, action) => object | An async function that returns extra fields merged into writes and used to scope reads |
hooks | object | Lifecycle hooks: beforeCreate, afterCreate, etc. |
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:
createCRUD(
{
router,
schema: $user,
model: userModel,
insertSchema: $userInput,
},
{
hooks: {
beforeCreate: ({ data }) => ({ ...data, createdBy: "system" }),
afterCreate: ({ data }) => console.log("Created:", data._id),
},
}
);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 }) => ({ ...data, createdBy: "system" }),
afterCreate: ({ data }) => console.log("Created:", data._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.