How to buildOctober 11, 20269 min read
Backend with Node.jsStep 3 of 8

REST API on Express: CRUD, validation, errors

In short

A REST API on Express is built from three parts: routes for each action on a resource (GET, POST, PATCH, DELETE on /api/tasks), middleware that parses JSON and logs requests, and a central error handler with four arguments. Input is checked with a Zod schema, and every error — an invalid field, a missing record, a conflict — becomes a response with the right code (400, 404, 409) in a single Problem Details format. In Express 5, errors from async handlers reach that handler automatically.

TutorialLevel: BeginnerExpressPractice: ~1.5–3 h

After this you will be able to

  • implement five CRUD endpoints on Express 5 with Router, async handlers and 200, 201, 204 codes
  • validate the body, path parameters and query string with Zod schemas and reject unknown fields
  • write a central error handler that returns 400, 404, 409 and 500 in the Problem Details format
  • check the API with curl and cover it with node:test + supertest tests

Verified with

Node.js 24 LTS (24.18)Express 5.3.0Zod 4.6.6supertest 7.3.1node:test

Checked against docs: October 11, 2026

Contents
  1. 01Express: the app, routes and middleware
  2. 02The resource and CRUD routes
  3. 03Validating input with a Zod schema
  4. 04Status codes and a single error format
  5. 05Storage: in memory first, a database later
  6. 06Checking: curl and automated tests
  7. 07Common mistakes, limitations and exercises

Express: the app, routes and middleware

In the Node.js lesson we wrote a server on node:http and ran into manual work: comparing paths, parsing parameters, building responses. Express is a minimalist framework on top of the same node:http that takes this over. The current major version is 5: at the time of checking, latest in the npm registry is 5.3.0, and the 4.x line is tagged latest-4. Express 5 requires Node.js 18 or newer; this lesson's code was verified on Node.js 24.18.

An entire Express app is a chain of middleware functions. Middleware receives the request req, the response res and a next function: it can respond itself or pass the request on by calling next(). A route is middleware too, just bound to a method and a path. If you pass an error to next, Express skips regular middleware and jumps to error handlers — functions with four arguments (err, req, res, next).

Request logexpress.json()Routes /api/tasks404 for the restError handler
The order of app.use() is the order in which a request passes the chain; an error from any step goes straight to the last one.

Create the project and install dependencies: Express, Zod for validation and supertest for tests. We run tests with the test runner built into Node.js, node --test, so no separate test framework is needed.

bash
mkdir tasks-api
cd tasks-api
npm init -y
npm pkg set type=module
npm pkg set private=true --json
npm pkg delete main scripts.test
npm pkg set scripts.dev="node --watch src/server.js" scripts.start="node src/server.js" scripts.test="node --test"
npm install express zod
npm install -D supertest
The tasks-api project: ES modules, dev/start/test scripts
package.json
{
  "name": "tasks-api",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js",
    "test": "node --test"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "private": true,
  "dependencies": {
    "express": "^5.3.0",
    "zod": "^4.6.6"
  },
  "devDependencies": {
    "supertest": "^7.3.1"
  }
}
The actual package.json after the commands above
src/app.js
import express from "express";
import { errorHandler, notFound } from "./errors.js";
import { tasksRouter } from "./tasks/router.js";

export function createApp({ store, logger = console }) {
  const app = express();
  app.disable("x-powered-by");

  app.use((req, res, next) => {
    const started = performance.now();
    res.on("finish", () => {
      const ms = (performance.now() - started).toFixed(1);
      logger.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
    });
    next();
  });
  app.use(express.json({ limit: "10kb" }));

  app.get("/health", (req, res) => res.json({ status: "ok" }));
  app.use("/api/tasks", tasksRouter(store));

  app.use(notFound);
  app.use(errorHandler(logger));
  return app;
}
Assembling the app: a log, JSON parsing with a 10 KB limit, routes, 404 and the central error handler
  • createApp receives the store and logger as parameters instead of importing them. Tests can then pass their own — a silent logger and a broken store — without starting a server
  • express.json() parses the body only with Content-Type: application/json. The default body limit is 100 KB; 10 KB is plenty for a tasks API, and a smaller limit leaves less room for large-body attacks
  • app.disable("x-powered-by") removes the header that tells everyone the server runs Express
  • The 404 handler and the error handler are registered last: Express walks the chain in order

The resource and CRUD routes

A REST resource is an entity with its own address. Ours is tasks: the collection /api/tasks and a single task /api/tasks/:id. CRUD (Create, Read, Update, Delete) is the four actions on a resource, mapped to HTTP methods. That gives five endpoints:

GET /api/tasks

Action
List with a done filter and limit/offset pagination
Success
200
Errors
400 — invalid parameters

GET /api/tasks/:id

Action
One task
Success
200
Errors
400 — id is not a number, 404

POST /api/tasks

Action
Create a task
Success
201 + Location
Errors
400, 409 — the title already exists

PATCH /api/tasks/:id

Action
Partial update
Success
200
Errors
400, 404, 409

DELETE /api/tasks/:id

Action
Delete
Success
204 with no body
Errors
400, 404
src/tasks/router.js
import { Router } from "express";
import { HttpError } from "../errors.js";
import { createTaskSchema, idSchema, listQuerySchema, updateTaskSchema } from "./schemas.js";

export function tasksRouter(store) {
  const router = Router();

  async function findTask(rawId) {
    const parsed = idSchema.safeParse(rawId);
    if (!parsed.success) throw new HttpError(400, "Task id must be a positive integer");
    const task = await store.get(parsed.data);
    if (!task) throw new HttpError(404, `Task ${parsed.data} not found`);
    return task;
  }

  async function assertTitleIsFree(title, exceptId) {
    const existing = await store.findByTitle(title);
    if (existing && existing.id !== exceptId) {
      throw new HttpError(409, `Task "${existing.title}" already exists (id ${existing.id})`);
    }
  }

  router.get("/", async (req, res) => {
    const query = listQuerySchema.parse(req.query);
    const { items, total } = await store.list(query);
    res.json({ data: items, meta: { total, limit: query.limit, offset: query.offset } });
  });

  router.get("/:id", async (req, res) => {
    res.json(await findTask(req.params.id));
  });

  router.post("/", async (req, res) => {
    const input = createTaskSchema.parse(req.body);
    await assertTitleIsFree(input.title);
    const task = await store.create(input);
    res.status(201).location(`${req.baseUrl}/${task.id}`).json(task);
  });

  router.patch("/:id", async (req, res) => {
    const task = await findTask(req.params.id);
    const patch = updateTaskSchema.parse(req.body);
    if (patch.title) await assertTitleIsFree(patch.title, task.id);
    res.json(await store.update(task.id, patch));
  });

  router.delete("/:id", async (req, res) => {
    const task = await findTask(req.params.id);
    await store.remove(task.id);
    res.status(204).end();
  });

  return router;
}
Task routes: async handlers that throw errors instead of sending them by hand

Notice that no handler has a try/catch. In Express 5, a handler that returns a rejected promise or throws inside an async function automatically passes the error to next(err) — both the migration guide and the error handling guide say so. In Express 4 such an error was lost, and every async route had to be wrapped manually or with a separate package.

terminal
$ node wildcard.js   # app.get("/api/*", ...)
…/node_modules/path-to-regexp/dist/index.js:108
                    throw new PathError(`Missing parameter name at index ${index}`, str);
                          ^

PathError [TypeError]: Missing parameter name at index 6: /api/*; visit https://git.new/pathToRegexpError for info
  originalPath: '/api/*'
}
The actual Express 5.3 error for an Express 4 style /api/* path: the app crashes when the route is registered

Validating input with a Zod schema

The server trusts nothing that arrives in a request: the body, path parameters or the query string. Checking fields by hand with typeof quickly becomes unreadable, so data is described with a schema. Zod 4 (4.6.6 at the time of checking) defines the schema in code, checks the data and returns it already shaped — trimmed and with defaults applied.

src/tasks/schemas.js
import * as z from "zod";

const title = z.string().trim().min(1, "Title is required").max(200);
const priority = z.enum(["low", "normal", "high"]);

export const createTaskSchema = z.strictObject({
  title,
  priority: priority.default("normal"),
  done: z.boolean().default(false),
});

export const updateTaskSchema = z
  .strictObject({
    title: title.optional(),
    priority: priority.optional(),
    done: z.boolean().optional(),
  })
  .refine((data) => Object.keys(data).length > 0, "Provide at least one field to update");

export const listQuerySchema = z.object({
  done: z.enum(["true", "false"]).transform((value) => value === "true").optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  offset: z.coerce.number().int().min(0).default(0),
});

export const idSchema = z.coerce.number().int().positive();
Schemas: create, partial update, list parameters and id

z.strictObject

What it does
Rejects unknown fields
Why
A client cannot sneak in id or createdAt — protection against mass assignment

.trim().min(1)

What it does
Trims whitespace, then checks length
Why
A title made of spaces does not pass

.default("normal")

What it does
Fills in a value if the field is missing
Why
The handler always gets a complete object

z.coerce.number()

What it does
Converts a string to a number
Why
Path and query parameters are always strings

.refine(...)

What it does
A custom rule for the whole object
Why
A PATCH with an empty body is an error, not a silent success

Schemas have two checking methods. parse returns the data or throws a ZodError — convenient in handlers: the error goes to the central handler, which turns it into a 400. safeParse does not throw but returns { success, data | error } — we use it for id to answer with our own message. The update schema is defined separately rather than via .partial() of the create schema, so defaults do not overwrite existing fields on PATCH.

Status codes and a single error format

An API client needs errors in one predictable format: the frontend then writes one parsing function instead of guessing each route's response shape. The standard for this is Problem Details, RFC 9457. The response has the application/problem+json type and the members type, title, status, detail and instance; extension members are allowed too — ours is errors with a per-field list. If type is omitted, the standard defines it as about:blank, and then title should match the status code's reason phrase.

src/errors.js
import { STATUS_CODES } from "node:http";
import * as z from "zod";

export class HttpError extends Error {
  constructor(status, detail) {
    super(detail);
    this.status = status;
  }
}

function sendProblem(res, status, detail, extra = {}) {
  res
    .status(status)
    .type("application/problem+json")
    .json({ title: STATUS_CODES[status], status, detail, ...extra });
}

export function notFound(req, res, next) {
  next(new HttpError(404, `Route ${req.method} ${req.originalUrl} not found`));
}

export function errorHandler(logger) {
  return (err, req, res, next) => {
    if (res.headersSent) return next(err);

    if (err instanceof z.ZodError) {
      const errors = err.issues.map((issue) => ({
        path: issue.path.join(".") || "body",
        message: issue.message,
      }));
      return sendProblem(res, 400, "Request validation failed", { errors });
    }
    if (err instanceof HttpError) return sendProblem(res, err.status, err.message);
    if (err.type === "entity.parse.failed") {
      return sendProblem(res, 400, "Request body is not valid JSON");
    }
    if (err.type === "entity.too.large") {
      return sendProblem(res, 413, "Request body is too large");
    }

    logger.error(err);
    sendProblem(res, 500, "Unexpected server error");
  };
}
The central handler: ZodError → 400, HttpError → its code, body parsing errors → 400 and 413, everything else → 500 with no details

200 OK

When in this API
A read or update succeeded
Body
A task or a list

201 Created

When in this API
A task was created
Body
The task + a Location header

204 No Content

When in this API
A task was deleted
Body
Empty

400 Bad Request

When in this API
Broken JSON, invalid fields or parameters
Body
Problem Details + errors

404 Not Found

When in this API
No such task or route
Body
Problem Details

409 Conflict

When in this API
The title is taken
Body
Problem Details

413 Payload Too Large

When in this API
The body exceeds the express.json limit
Body
Problem Details

500 Internal Server Error

When in this API
An unexpected error
Body
Problem Details with no details

For invalid fields some APIs respond with 422 Unprocessable Content — as our HTTP and JSON lesson did, and as the example in RFC 9457 itself does. Both are common in practice; what matters is picking one and documenting it. The conventions in detail — error format, pagination, versioning — are covered in the API design lesson; here we apply them in minimal form.

Storage: in memory first, a database later

To focus on HTTP, tasks are kept in a Map in process memory for now. One important detail: the store methods are asynchronous even though Map is synchronous. When the store is replaced with PostgreSQL its methods will be asynchronous anyway, and the routes will not need rewriting: just pass createApp another object with the same methods.

src/tasks/store.js
export class TaskStore {
  #tasks = new Map();
  #nextId = 1;

  async list({ done, limit, offset }) {
    let items = [...this.#tasks.values()];
    if (done !== undefined) items = items.filter((task) => task.done === done);
    return { items: items.slice(offset, offset + limit), total: items.length };
  }

  async get(id) {
    return this.#tasks.get(id) ?? null;
  }

  async findByTitle(title) {
    const wanted = title.toLowerCase();
    for (const task of this.#tasks.values()) {
      if (task.title.toLowerCase() === wanted) return task;
    }
    return null;
  }

  async create(data) {
    const task = { id: this.#nextId++, ...data, createdAt: new Date().toISOString() };
    this.#tasks.set(task.id, task);
    return task;
  }

  async update(id, patch) {
    const task = { ...this.#tasks.get(id), ...patch };
    this.#tasks.set(id, task);
    return task;
  }

  async remove(id) {
    this.#tasks.delete(id);
  }
}
An in-memory store: data disappears on restart, and with two server instances each has its own
src/server.js
import { createApp } from "./app.js";
import { TaskStore } from "./tasks/store.js";

const port = Number(process.env.PORT ?? 8080);
const app = createApp({ store: new TaskStore() });

app.listen(port, "127.0.0.1", (error) => {
  if (error) {
    console.error(`Cannot start server: ${error.code}`);
    process.exit(1);
  }
  console.log(`Tasks API: http://127.0.0.1:${port}`);
});
The entry point: in Express 5 a startup error such as a busy port arrives in the app.listen callback
terminal
$ npm start   # second instance, port 8080 already in use

> tasks-api@1.0.0 start
> node src/server.js

Cannot start server: EADDRINUSE
Actually starting a second instance: EADDRINUSE caught in the callback, the process exited with code 1

An in-memory store is fine for a lesson and tests, but not for real work: data vanishes on restart, and the title uniqueness check between find and create is not protected against two simultaneous requests. In a database such rules are enforced with a unique index, and the conflict is caught from the database error — a topic for the Databases direction.

Checking: curl and automated tests

Run npm run dev and walk through the whole scenario: create two tasks, get a page of the list, mark a task done, filter, delete. The commands are for bash (macOS, Linux, Git Bash on Windows).

terminal
$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "Call the client", "priority": "high"}'
HTTP/1.1 201 Created
Location: /api/tasks/1
Content-Type: application/json; charset=utf-8
Content-Length: 104
ETag: W/"68-Oz2mnFVsGJlVZ4AF7cp/JbdJJXQ"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":1,"title":"Call the client","priority":"high","done":false,"createdAt":"2026-10-11T23:23:02.359Z"}

$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "Prepare the contract"}'
{"id":2,"title":"Prepare the contract","priority":"normal","done":false,"createdAt":"2026-10-11T23:23:02.388Z"}

$ curl -s 'http://127.0.0.1:8080/api/tasks?limit=1'
{"data":[{"id":1,"title":"Call the client","priority":"high","done":false,"createdAt":"2026-10-11T23:23:02.359Z"}],"meta":{"total":2,"limit":1,"offset":0}}

$ curl -s -X PATCH http://127.0.0.1:8080/api/tasks/1 -H 'Content-Type: application/json' -d '{"done": true}'
{"id":1,"title":"Call the client","priority":"high","done":true,"createdAt":"2026-10-11T23:23:02.359Z"}

$ curl -s 'http://127.0.0.1:8080/api/tasks?done=true'
{"data":[{"id":1,"title":"Call the client","priority":"high","done":true,"createdAt":"2026-10-11T23:23:02.359Z"}],"meta":{"total":1,"limit":20,"offset":0}}

$ curl -si -X DELETE http://127.0.0.1:8080/api/tasks/2
HTTP/1.1 204 No Content
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
The happy path: 201 with Location, pagination in meta, PATCH, the done=true filter and 204 with no body
terminal
$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "  ", "priority": "urgent", "id": 7}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
Content-Length: 276
ETag: W/"114-ReqTpYGYyIiQxhc06Nl/h2R0V8Y"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"title","message":"Title is required"},{"path":"priority","message":"Invalid option: expected one of \"low\"|\"normal\"|\"high\""},{"path":"body","message":"Unrecognized key: \"id\""}]}

$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": '
{"title":"Bad Request","status":400,"detail":"Request body is not valid JSON"}

$ curl -s -X POST http://127.0.0.1:8080/api/tasks -d 'title=Call'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"body","message":"Invalid input: expected object, received undefined"}]}

$ curl -si -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title": "call the client"}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
Content-Length: 91
ETag: W/"5b-JwoMGqDcMhfgj8sjRVM1/SweD8Y"
Date: Sun, 11 Oct 2026 23:23:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"title":"Conflict","status":409,"detail":"Task \"Call the client\" already exists (id 1)"}

$ curl -s http://127.0.0.1:8080/api/tasks/2
{"title":"Not Found","status":404,"detail":"Task 2 not found"}

$ curl -s http://127.0.0.1:8080/api/tasks/abc
{"title":"Bad Request","status":400,"detail":"Task id must be a positive integer"}

$ curl -s -X PATCH http://127.0.0.1:8080/api/tasks/1 -H 'Content-Type: application/json' -d '{}'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"body","message":"Provide at least one field to update"}]}

$ curl -s 'http://127.0.0.1:8080/api/tasks?limit=500'
{"title":"Bad Request","status":400,"detail":"Request validation failed","errors":[{"path":"limit","message":"Too big: expected number to be <=100"}]}

$ curl -s http://127.0.0.1:8080/api/projects
{"title":"Not Found","status":404,"detail":"Route GET /api/projects not found"}
Errors in one format: invalid fields and an extra id, broken JSON, a body without Content-Type, a duplicate, a missing task, an invalid id, an empty PATCH, limit out of range and an unknown route

The third request shows an Express 5 change: a body without Content-Type: application/json is not parsed, and req.body is undefined (Express 4 gave an empty object). The schema honestly reports that it expected an object. The extra id field is rejected thanks to z.strictObject.

terminal
$ curl -s -X POST http://127.0.0.1:8080/api/tasks -H "Content-Type: application/json" --data-binary @task.json
{"id":3,"title":"Позвонить клиенту","priority":"normal","done":false,"createdAt":"2026-10-11T23:23:02.780Z"}
A body from a file: the Cyrillic title arrived intact

A manual check does not repeat itself. An automated test locks the behavior in: supertest takes an Express app, binds it to a free port itself and sends requests, while node:test and node:assert are built into Node.js. A fresh app with an empty store is created before each test, so tests do not depend on each other.

test/tasks.test.js
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import request from "supertest";
import { createApp } from "../src/app.js";
import { TaskStore } from "../src/tasks/store.js";

const silent = { log() {}, error() {} };
let app;

beforeEach(() => {
  app = createApp({ store: new TaskStore(), logger: silent });
});

describe("POST /api/tasks", () => {
  it("creates a task: 201, Location and defaults", async () => {
    const res = await request(app).post("/api/tasks").send({ title: "  Call the client " });
    assert.equal(res.status, 201);
    assert.equal(res.headers.location, "/api/tasks/1");
    assert.equal(res.body.title, "Call the client");
    assert.equal(res.body.priority, "normal");
    assert.equal(res.body.done, false);
  });

  it("rejects invalid fields with 400 and a problem+json body", async () => {
    const res = await request(app).post("/api/tasks").send({ title: "", priority: "urgent" });
    assert.equal(res.status, 400);
    assert.match(res.headers["content-type"], /^application\/problem\+json/);
    assert.deepEqual(res.body.errors.map((e) => e.path), ["title", "priority"]);
  });

  it("rejects malformed JSON with 400", async () => {
    const res = await request(app)
      .post("/api/tasks")
      .set("Content-Type", "application/json")
      .send('{"title": ');
    assert.equal(res.status, 400);
    assert.equal(res.body.detail, "Request body is not valid JSON");
  });

  it("returns 409 for a duplicate title (case-insensitive)", async () => {
    await request(app).post("/api/tasks").send({ title: "Call the client" });
    const res = await request(app).post("/api/tasks").send({ title: "CALL THE CLIENT" });
    assert.equal(res.status, 409);
  });
});

describe("GET, PATCH and DELETE /api/tasks/:id", () => {
  beforeEach(async () => {
    await request(app).post("/api/tasks").send({ title: "Call the client" });
    await request(app).post("/api/tasks").send({ title: "Prepare the contract" });
  });

  it("lists tasks with pagination and a filter", async () => {
    const page = await request(app).get("/api/tasks?limit=1&offset=1");
    assert.equal(page.status, 200);
    assert.deepEqual(page.body.meta, { total: 2, limit: 1, offset: 1 });
    assert.equal(page.body.data[0].title, "Prepare the contract");

    const done = await request(app).get("/api/tasks?done=true");
    assert.equal(done.body.meta.total, 0);
  });

  it("updates a task partially", async () => {
    const res = await request(app).patch("/api/tasks/1").send({ done: true });
    assert.equal(res.status, 200);
    assert.equal(res.body.done, true);
    assert.equal(res.body.title, "Call the client");
  });

  it("rejects an empty update and a taken title", async () => {
    const empty = await request(app).patch("/api/tasks/1").send({});
    assert.equal(empty.status, 400);
    const taken = await request(app).patch("/api/tasks/1").send({ title: "Prepare the contract" });
    assert.equal(taken.status, 409);
  });

  it("deletes a task: 204, then 404", async () => {
    const first = await request(app).delete("/api/tasks/2");
    assert.equal(first.status, 204);
    const second = await request(app).delete("/api/tasks/2");
    assert.equal(second.status, 404);
  });

  it("returns 400 for a non-numeric id and 404 for an unknown route", async () => {
    assert.equal((await request(app).get("/api/tasks/abc")).status, 400);
    assert.equal((await request(app).get("/api/projects")).status, 404);
  });
});

describe("unexpected errors", () => {
  it("answers 500 without leaking details and logs the error", async () => {
    const logged = [];
    const brokenStore = {
      async list() {
        throw new Error("connection to database lost");
      },
    };
    const broken = createApp({ store: brokenStore, logger: { log() {}, error: (e) => logged.push(e) } });
    const res = await request(broken).get("/api/tasks");
    assert.equal(res.status, 500);
    assert.equal(res.body.detail, "Unexpected server error");
    assert.doesNotMatch(JSON.stringify(res.body), /database/);
    assert.equal(logged[0].message, "connection to database lost");
  });
});
10 tests: create, validation, broken JSON, 409, pagination, PATCH, DELETE, 400/404 and a 500 without leaking details
terminal
$ npm test

> tasks-api@1.0.0 test
> node --test

▶ POST /api/tasks
  ✔ creates a task: 201, Location and defaults (34.175ms)
  ✔ rejects invalid fields with 400 and a problem+json body (9.5175ms)
  ✔ rejects malformed JSON with 400 (4.2543ms)
  ✔ returns 409 for a duplicate title (case-insensitive) (6.3681ms)
✔ POST /api/tasks (55.2454ms)
▶ GET, PATCH and DELETE /api/tasks/:id
  ✔ lists tasks with pagination and a filter (11.4126ms)
  ✔ updates a task partially (7.5939ms)
  ✔ rejects an empty update and a taken title (8.3594ms)
  ✔ deletes a task: 204, then 404 (7.6509ms)
  ✔ returns 400 for a non-numeric id and 404 for an unknown route (7.2247ms)
✔ GET, PATCH and DELETE /api/tasks/:id (42.6496ms)
▶ unexpected errors
  ✔ answers 500 without leaking details and logs the error (2.7755ms)
✔ unexpected errors (2.8955ms)
ℹ tests 10
ℹ suites 3
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 371.4657
The actual node --test output on Node.js 24.18

Common mistakes, limitations and exercises

req.body is undefined

Cause
express.json() is not registered, or the client did not send Content-Type: application/json
Fix
Register the parser before the routes; validate the body with a schema

Missing parameter name error at startup

Cause
An Express 4 path pattern: /*, :param?
Fix
/*splat, {/:param} — path-to-regexp v8 syntax

An HTML response with a stack instead of JSON

Cause
Your error handler did not run: it has three arguments or is registered before the routes
Fix
Four arguments (err, req, res, next), registered last

res.json(obj, 201) responds 200, not 201

Cause
The Express 4 signature is not supported: the second argument is silently ignored
Fix
res.status(201).json(obj)

A request hangs with no response

Cause
Middleware neither called next() nor sent a response
Fix
Every middleware branch must end with a response or next()

An error in a setTimeout callback or an old-style API crashes the process

Cause
Express only catches synchronous errors and rejected handler promises
Fix
Catch it inside the callback and pass it to next(err)

This API is for learning. Before production it lacks authentication and permission checks, rate limiting, a real database with a unique index, security headers and structured logs. The Express security best practices guide recommends, among other things, keeping dependencies up to date and not revealing your technology stack. If a project needs a strict module structure and dependency injection out of the box, look at NestJS — by default it runs on top of the same Express.

How this was verified6 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.