Skip to content
Express.js projects 5 min read

Project: To-Do REST API

A to-do REST API is the canonical starter project for learning server-side Express: it touches every fundamental of a real backend without the noise of authentication or payments. In this guide you will build a full CRUD API backed by MongoDB through Mongoose, with input validation, structured error handling, and a small test suite. By the end you will have a project layout that scales cleanly into the larger applications covered elsewhere in this section.

Project setup

Create a new project and install the runtime dependencies. We use Express for routing, Mongoose as the MongoDB ODM, and dotenv to load configuration from a .env file.

mkdir todo-api && cd todo-api
npm init -y
npm install express mongoose dotenv
npm install --save-dev jest supertest

Add a .env file with your connection string and port. Never commit this file.

PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/todoapp

A clean folder structure keeps concerns separated as the project grows:

todo-api/
├── src/
│   ├── app.js          # Express app (no listen) — testable
│   ├── server.js       # connects DB + starts listening
│   ├── models/Todo.js
│   └── routes/todos.js
├── tests/todos.test.js
└── .env

The Mongoose model

The model defines the shape of a to-do document and enforces validation at the database layer. Mongoose validators run before any write, so invalid data never reaches MongoDB. timestamps: true adds createdAt and updatedAt automatically.

// src/models/Todo.js
const mongoose = require("mongoose");

const todoSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: [true, "title is required"],
      trim: true,
      maxlength: [200, "title must be 200 characters or fewer"],
    },
    completed: { type: Boolean, default: false },
  },
  { timestamps: true }
);

module.exports = mongoose.model("Todo", todoSchema);

Routes with the Express Router

Group the CRUD endpoints in a dedicated router so the main app file stays small. Each handler is async and wraps its logic in try/catch, forwarding failures to Express via next(err). The five routes map directly to the standard REST verbs.

MethodPathAction
GET/api/todosList all to-dos
GET/api/todos/:idFetch one to-do
POST/api/todosCreate a to-do
PATCH/api/todos/:idUpdate a to-do
DELETE/api/todos/:idRemove a to-do
// src/routes/todos.js
const express = require("express");
const Todo = require("../models/Todo");

const router = express.Router();

router.get("/", async (req, res, next) => {
  try {
    const todos = await Todo.find().sort("-createdAt");
    res.json(todos);
  } catch (err) {
    next(err);
  }
});

router.get("/:id", async (req, res, next) => {
  try {
    const todo = await Todo.findById(req.params.id);
    if (!todo) return res.status(404).json({ error: "Todo not found" });
    res.json(todo);
  } catch (err) {
    next(err);
  }
});

router.post("/", async (req, res, next) => {
  try {
    const todo = await Todo.create({
      title: req.body.title,
      completed: req.body.completed,
    });
    res.status(201).json(todo);
  } catch (err) {
    next(err);
  }
});

router.patch("/:id", async (req, res, next) => {
  try {
    const todo = await Todo.findByIdAndUpdate(req.params.id, req.body, {
      new: true,
      runValidators: true,
    });
    if (!todo) return res.status(404).json({ error: "Todo not found" });
    res.json(todo);
  } catch (err) {
    next(err);
  }
});

router.delete("/:id", async (req, res, next) => {
  try {
    const todo = await Todo.findByIdAndDelete(req.params.id);
    if (!todo) return res.status(404).json({ error: "Todo not found" });
    res.status(204).end();
  } catch (err) {
    next(err);
  }
});

module.exports = router;

Always pass runValidators: true to findByIdAndUpdate. By default Mongoose skips schema validation on update operations, so without it a PATCH could write an invalid title.

Wiring up the app and centralized error handling

Keep the Express app separate from the server bootstrap. The app file mounts middleware and routes but never calls listen, which makes it trivial to import in tests. A single error-handling middleware (four arguments) catches everything forwarded by next(err), translating Mongoose validation and cast errors into clean HTTP responses.

// src/app.js
const express = require("express");
const todosRouter = require("./routes/todos");

const app = express();
app.use(express.json());

app.use("/api/todos", todosRouter);

// 404 for unmatched routes
app.use((req, res) => res.status(404).json({ error: "Not found" }));

// Centralized error handler — must have 4 args
app.use((err, req, res, next) => {
  if (err.name === "ValidationError") {
    return res.status(400).json({ error: err.message });
  }
  if (err.name === "CastError") {
    return res.status(400).json({ error: "Invalid id format" });
  }
  console.error(err);
  res.status(500).json({ error: "Internal server error" });
});

module.exports = app;
// src/server.js
require("dotenv").config();
const mongoose = require("mongoose");
const app = require("./app");

const { PORT = 3000, MONGODB_URI } = process.env;

mongoose
  .connect(MONGODB_URI)
  .then(() => {
    app.listen(PORT, () => console.log(`API running on port ${PORT}`));
  })
  .catch((err) => {
    console.error("DB connection failed:", err.message);
    process.exit(1);
  });

Express 5 automatically forwards rejected promises from async handlers to the error middleware, so the try/catch blocks become optional. On Express 4 they are required — an uncaught rejection will hang the request.

Trying it out

Start MongoDB locally, run node src/server.js, and exercise the API with curl.

curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Write the docs"}'

Output:

{
  "title": "Write the docs",
  "completed": false,
  "_id": "665b1f2c8a4e9b0012a3c4d5",
  "createdAt": "2026-06-14T10:12:44.901Z",
  "updatedAt": "2026-06-14T10:12:44.901Z",
  "__v": 0
}

Posting an empty body triggers the schema validator and the error handler:

curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" -d '{}'

Output:

{ "error": "Todo validation failed: title: title is required" }

Testing with Jest and Supertest

Because app.js exports the app without listening, Supertest can mount it directly and fire real HTTP requests in memory — no running server required. Point the test at a throwaway database and clean up afterward.

// tests/todos.test.js
const request = require("supertest");
const mongoose = require("mongoose");
const app = require("../src/app");

beforeAll(() => mongoose.connect("mongodb://127.0.0.1:27017/todoapp_test"));
afterAll(() => mongoose.connection.dropDatabase().then(() => mongoose.disconnect()));

test("creates and lists a todo", async () => {
  const created = await request(app)
    .post("/api/todos")
    .send({ title: "Test item" })
    .expect(201);

  expect(created.body.completed).toBe(false);

  const list = await request(app).get("/api/todos").expect(200);
  expect(list.body).toHaveLength(1);
});

test("rejects a todo without a title", async () => {
  await request(app).post("/api/todos").send({}).expect(400);
});

Best practices

  • Keep the Express app free of listen so it stays importable and testable; bootstrap networking in a separate file.
  • Validate at the schema layer with Mongoose and let a single error-handling middleware translate those errors into HTTP status codes.
  • Always set runValidators: true on update operations so validation is not silently skipped.
  • Return precise status codes: 201 on create, 204 on delete with no body, 404 for missing resources, 400 for bad input.
  • Load configuration from environment variables via dotenv; never hard-code connection strings or commit .env.
  • Run tests against a dedicated test database and drop it in teardown to keep runs isolated and repeatable.
Last updated June 14, 2026
Was this helpful?