The Todo API
Build validated CRUD endpoints by hand, then with createCRUD, plus pagination.
We'll build the API two ways: first by hand to understand the request lifecycle, then with the createCRUD helper that generates the same endpoints in a few lines. Finally we'll add a paginated list endpoint.
Because Thunder uses file-based routing, the file name todos.ts becomes the /todos prefix.
Option A: By Hand
This version makes every validation step explicit - great for learning, and the right choice when you need custom logic.
import { Router } from "@/core/http/router.ts";
import { bodyAsJson } from "@/core/http/utils.ts";
import { Response } from "@/core/http/response.ts";
import { todoModel, $todo, $todoInput } from "@/schemas/todo.ts";
import z from "zod";
export default new Router("/", function todos(router) {
router.post("/", function createTodo() {
const $return = z.object({ _id: z.string() });
return {
shape: () => ({ body: $todoInput, return: $return }),
handler: async (req) => {
const body = $todoInput.parse(await bodyAsJson(req));
const { insertedId } = await todoModel.insertOne(
$todo.strictParse(body)
);
return Response.created({ _id: insertedId.toString() });
},
};
});
});router.get("/", function listTodos() {
const $return = z.array($todo);
return {
shape: () => ({ return: $return }),
handler: async () => {
const todos = await todoModel.find().sort({ createdAt: -1 }).toArray();
return Response.json(todos);
},
};
});import { paramsAsJson } from "@/core/http/utils.ts";
import { ObjectId } from "mongodb";
router.get("/:id", function getTodo() {
const $params = z.object({ id: z.string() });
return {
shape: () => ({ params: $params, return: $todo }),
handler: async (req) => {
const { id } = $params.parse(paramsAsJson(req));
const todo = await todoModel.findOne({ _id: new ObjectId(id) });
if (!todo) throw Response.notFound("Todo not found");
return Response.json(todo);
},
};
});router.patch("/:id", function updateTodo() {
const $params = z.object({ id: z.string() });
const $body = $todoInput.partial();
const $return = z.object({ updated: z.boolean() });
return {
shape: () => ({ params: $params, body: $body, return: $return }),
handler: async (req) => {
const { id } = $params.parse(paramsAsJson(req));
const body = $body.parse(await bodyAsJson(req));
await todoModel.updateOne({ _id: new ObjectId(id) }, { $set: body });
return Response.ok({ updated: true });
},
};
});router.del("/:id", function deleteTodo() {
const $params = z.object({ id: z.string() });
const $return = z.object({ deleted: z.boolean() });
return {
shape: () => ({ params: $params, return: $return }),
handler: async (req) => {
const { id } = $params.parse(paramsAsJson(req));
await todoModel.deleteOne({ _id: new ObjectId(id) });
return Response.ok({ deleted: true });
},
};
});Use .del() for DELETE - .delete is a reserved keyword. See HTTP Methods.
Option B: With createCRUD
The same five endpoints (plus a count endpoint) in a handful of lines:
import { Router } from "@/core/http/router.ts";
import { createCRUD } from "@/core/utils/createCRUD.ts";
import { todoModel, $todo, $todoInput } from "@/schemas/todo.ts";
export default new Router("/", function todos(router) {
createCRUD({
router,
schema: $todo,
model: todoModel,
insertSchema: $todoInput,
});
}).group("Todos");This generates:
| Method | Path | Description |
|---|---|---|
POST | /todos | Create a todo |
GET | /todos | List todos |
GET | /todos/:id | Get one todo |
GET | /todos/count | Count todos |
PATCH | /todos/:id | Update a todo |
DELETE | /todos/:id | Delete a todo |
See the full createCRUD reference for options like disable, isolationFields, and lifecycle hooks.
Adding Pagination
For long lists, accept offset and limit query parameters. Note the use of z.coerce.number() - query params always arrive as strings:
import { queryAsJson } from "@/core/http/utils.ts";
router.get("/", function listTodos() {
const $query = z.object({
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
});
const $return = z.object({
results: z.array($todo),
count: z.number(),
});
return {
shape: () => ({ query: $query, return: $return }),
handler: async (req) => {
const { offset, limit } = $query.parse(queryAsJson(req));
const [results, count] = await Promise.all([
todoModel.find().sort({ createdAt: -1 }).skip(offset).limit(limit).toArray(),
todoModel.countDocuments(),
]);
return Response.json({ results, count });
},
};
});A request to GET /todos?offset=20&limit=20 returns the second page. Next, let's give our API a frontend.