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.

routes/todos.ts
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:

routes/todos.ts
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:

MethodPathDescription
POST/todosCreate a todo
GET/todosList todos
GET/todos/:idGet one todo
GET/todos/countCount todos
PATCH/todos/:idUpdate a todo
DELETE/todos/:idDelete 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:

routes/todos.ts
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.


On this page