Building a REST API

A REST API is a web service that lets client applications create, read, update, and delete data over plain HTTP, using a small predictable set of URLs and verbs instead of a custom protocol. In Node.js, Express is the tool almost everyone reaches for to build one: it adds routing, request/response helpers, and a middleware pipeline on top of Node’s built-in http module. This lesson builds a complete task-management API from scratch — first as a simple in-memory service, then as a version that persists to disk and handles errors the way a production API should.

Overview: What Makes an API “RESTful”, and What Express Adds

REST (Representational State Transfer) is a style, not a library. A RESTful API organizes everything around resources (nouns, like tasks or users), addressed by URLs, and manipulated with a small set of HTTP methods that map onto CRUD:

  • GET /api/tasks — read a collection
  • GET /api/tasks/:id — read one resource
  • POST /api/tasks — create a resource
  • PUT /api/tasks/:id or PATCH /api/tasks/:id — update a resource
  • DELETE /api/tasks/:id — remove a resource

REST also expects the API to be stateless: every request carries everything the server needs (an auth token, an id) rather than relying on server-side session state between calls, and the server communicates outcomes through HTTP status codes, not just the response body.

Express itself does not know anything about REST — it just gives you a fast way to wire a URL + method to a handler function, plus a middleware chain: an ordered list of functions that each request passes through, any of which can inspect or modify req/res, end the response, or call next() to pass control along. Under the hood, Express registers one listener on Node’s raw http.Server, and every incoming request runs through that middleware chain synchronously, in the order the middleware was registered with app.use() and the routes were registered with app.get()/app.post()/etc. The routing and middleware logic itself is synchronous JavaScript; it is your handlers — reading a file, querying a database — that do the actual asynchronous I/O, which libuv and the underlying driver offload so the event loop stays free to serve other requests while that I/O is in flight.

One detail worth understanding early: an incoming request body arrives as a stream of bytes, not a ready-made JavaScript object. The express.json() middleware reads that stream into a buffer, waits until it has ended, and only then parses it as JSON onto req.body. If you forget to register that middleware, req.body stays undefined no matter what the client sent.

This lesson targets Express 5, the current stable major version. The most important change from Express 4 for API code: if an async route handler throws or its returned promise rejects, Express 5 automatically forwards that error to your error-handling middleware — in Express 4 you had to wrap every async handler yourself or the process could crash with an unhandled rejection.

Syntax

app.METHOD(path, handler)      // app.get, app.post, app.put, app.patch, app.delete
app.use([path], middleware)    // runs for every matching request, in registration order
app.use((err, req, res, next) => { ... })  // 4 args = error-handling middleware
Piece What it gives you
req.params Named route placeholders, e.g. :id in /api/tasks/:id
req.query Parsed query-string values, e.g. ?done=true
req.body Parsed request body — requires express.json() middleware first
res.status(code) Sets the HTTP status code; chainable
res.json(data) Serializes data, sets Content-Type: application/json, and ends the response
res.end() Ends the response with no body (used with 204 No Content)
next(err) Skips remaining normal middleware and jumps straight to error-handling middleware

Relevant status codes for a CRUD API:

Code Meaning Used for
200 OK Successful GET, PUT, PATCH
201 Created Successful POST that created a resource
204 No Content Successful DELETE, no body to return
400 Bad Request Invalid or missing input from the client
404 Not Found Route or resource id does not exist
500 Internal Server Error Unexpected server-side failure

Examples

Example 1: A complete in-memory CRUD API

Install Express first:

npm install express

Then a full task API, saved as app.js and run with node app.js (this assumes "type": "module" in package.json, since it uses import):

import express from "express";

const app = express();
const PORT = process.env.PORT ?? 3000;

app.use(express.json());

let tasks = [
  { id: 1, title: "Learn Node.js", done: false },
  { id: 2, title: "Build a REST API", done: false },
];
let nextId = 3;

app.get("/api/tasks", (req, res) => {
  res.json(tasks);
});

app.get("/api/tasks/:id", (req, res) => {
  const task = tasks.find((t) => t.id === Number(req.params.id));
  if (!task) {
    return res.status(404).json({ error: "Task not found" });
  }
  res.json(task);
});

app.post("/api/tasks", (req, res) => {
  const { title } = req.body;
  if (typeof title !== "string" || title.trim() === "") {
    return res.status(400).json({ error: "title is required" });
  }
  const task = { id: nextId++, title, done: false };
  tasks.push(task);
  res.status(201).json(task);
});

app.put("/api/tasks/:id", (req, res) => {
  const task = tasks.find((t) => t.id === Number(req.params.id));
  if (!task) {
    return res.status(404).json({ error: "Task not found" });
  }
  const { title, done } = req.body;
  if (title !== undefined) task.title = title;
  if (done !== undefined) task.done = done;
  res.json(task);
});

app.delete("/api/tasks/:id", (req, res) => {
  const index = tasks.findIndex((t) => t.id === Number(req.params.id));
  if (index === -1) {
    return res.status(404).json({ error: "Task not found" });
  }
  tasks.splice(index, 1);
  res.status(204).end();
});

app.use((req, res) => {
  res.status(404).json({ error: "Route not found" });
});

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: "Internal server error" });
});

app.listen(PORT, () => {
  console.log(`API listening on http://localhost:${PORT}`);
});

Output:

API listening on http://localhost:3000

That single log line is all that prints on startup — everything else happens per-request. Note the two app.use() calls at the bottom: the first (3 arguments) catches any request that matched no route and returns a clean 404; the second (4 arguments) is the error-handling middleware, which Express recognizes purely by its arity and only invokes when something calls next(err) or, in Express 5, when an async handler’s promise rejects.

Example 2: Testing it with curl

curl -s http://localhost:3000/api/tasks

curl -s -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Write tests"}'

curl -s -X PUT http://localhost:3000/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"done":true}'

curl -s -X DELETE http://localhost:3000/api/tasks/2 -i

Output:

[{"id":1,"title":"Learn Node.js","done":false},{"id":2,"title":"Build a REST API","done":false}]

{"id":3,"title":"Write tests","done":false}

{"id":1,"title":"Learn Node.js","done":true}

HTTP/1.1 204 No Content

Each call exercises one route: GET returns the current array, POST returns the newly created task with a 201-equivalent body (curl’s -s hides the status line unless you add -i), PUT returns the updated task, and DELETE returns an empty body with status 204 — there is nothing to send back once a resource is gone.

Example 3: Persisting to disk with async/await and automatic error forwarding

An in-memory array disappears every time the process restarts. Here the same idea reads and writes a JSON file with node:fs/promises, and relies on Express 5’s automatic promise-rejection handling instead of a manual try/catch in every route — if loadTasks() or saveTasks() rejects, Express passes the error straight to the error-handling middleware below:

import express from "express";
import { readFile, writeFile } from "node:fs/promises";

const app = express();
const PORT = process.env.PORT ?? 3000;
const DB_PATH = new URL("./tasks.json", import.meta.url);

app.use(express.json());

async function loadTasks() {
  const data = await readFile(DB_PATH, "utf8");
  return JSON.parse(data);
}

async function saveTasks(tasks) {
  await writeFile(DB_PATH, JSON.stringify(tasks, null, 2));
}

app.get("/api/tasks", async (req, res) => {
  const tasks = await loadTasks();
  res.json(tasks);
});

app.post("/api/tasks", async (req, res) => {
  const { title } = req.body;
  if (typeof title !== "string" || title.trim() === "") {
    return res.status(400).json({ error: "title is required" });
  }

  const tasks = await loadTasks();
  const task = { id: Date.now(), title, done: false };
  tasks.push(task);
  await saveTasks(tasks);

  res.status(201).json(task);
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "Internal server error" });
});

app.listen(PORT, () => {
  console.log(`API listening on http://localhost:${PORT}`);
});

Before running this, create an empty database file next to app.js:

[]

If tasks.json is missing, readFile rejects with an ENOENT error; because the handler is async, Express 5 catches that rejection for you and routes it to the error middleware, which logs it and answers with a clean 500 instead of crashing the process. This is the single biggest ergonomic improvement Express 5 made for API code — in Express 4 an uncaught rejection inside a route handler could otherwise take the whole server down.

Example 4: Filtering and pagination on a collection route

Real list endpoints rarely return everything unfiltered. This route reads optional query-string parameters to filter by status and page through results:

app.get("/api/tasks", (req, res) => {
  let result = tasks;

  if (req.query.done !== undefined) {
    const done = req.query.done === "true";
    result = result.filter((t) => t.done === done);
  }

  const page = Number(req.query.page ?? 1);
  const limit = Number(req.query.limit ?? 10);
  const start = (page - 1) * limit;

  res.json({
    page,
    limit,
    total: result.length,
    tasks: result.slice(start, start + limit),
  });
});

Output for curl "http://localhost:3000/api/tasks?done=false&page=1&limit=10":

{"page":1,"limit":10,"total":1,"tasks":[{"id":2,"title":"Build a REST API","done":false}]}

req.query values are always strings, which is why done is compared against the literal string "true" rather than the boolean true, and why page/limit are explicitly converted with Number() before doing arithmetic with them.

How Express Handles a Request, Step by Step

  • Node’s http server receives the raw request and emits a request event; Express’s single listener for that event takes over.
  • Express walks its middleware stack in registration order. express.json() runs first (if registered early): it buffers the incoming request stream, and only after the stream ends does it parse the buffered bytes as JSON and set req.body.
  • Express matches the request’s method and path against registered routes, top to bottom, and invokes the first matching handler.
  • Inside the handler, synchronous code (array lookups, validation) runs immediately, in order. An awaited call (reading a file, querying a database) suspends that handler function only — the event loop is free to process other requests while the I/O completes off-thread via libuv or the database driver.
  • Calling res.json() or res.end() writes the status line and headers, flushes the body, and ends the response. Once that happens, writing to res again throws ERR_HTTP_HEADERS_SENT.
  • If no route matched, control falls through to your catch-all app.use() 404 handler.
  • If a handler calls next(err), or (in Express 5) an async handler’s promise rejects, Express skips every remaining normal middleware and route and jumps straight to the first 4-argument error-handling middleware.

Common Mistakes

1. Forgetting the JSON body-parsing middleware

Without express.json(), req.body is always undefined — not an error, just silently wrong data:

const app = express();

app.post("/api/tasks", (req, res) => {
  const { title } = req.body; // req.body is undefined here
  res.status(201).json({ id: 1, title });
});

Register the middleware before any route that reads req.body:

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

app.post("/api/tasks", (req, res) => {
  const { title } = req.body;
  res.status(201).json({ id: 1, title });
});

2. Blocking the event loop with synchronous file I/O in a route

Using the *Sync fs functions inside a request handler blocks the entire event loop — every other in-flight request has to wait until the disk read finishes:

import { readFileSync } from "node:fs";

app.get("/api/tasks", (req, res) => {
  const tasks = JSON.parse(readFileSync("./tasks.json", "utf8"));
  res.json(tasks);
});

Use the promise-based API so the read is offloaded and other requests keep flowing:

import { readFile } from "node:fs/promises";

app.get("/api/tasks", async (req, res) => {
  const data = await readFile("./tasks.json", "utf8");
  res.json(JSON.parse(data));
});

3. Forgetting to return after sending an early response

Without a return, execution falls through to the next line even after a response has already been sent, which throws ERR_HTTP_HEADERS_SENT:

app.get("/api/tasks/:id", (req, res) => {
  const task = tasks.find((t) => t.id === Number(req.params.id));
  if (!task) {
    res.status(404).json({ error: "Task not found" });
  }
  res.json(task); // still runs, and crashes if headers were already sent
});

Always return from an early response:

app.get("/api/tasks/:id", (req, res) => {
  const task = tasks.find((t) => t.id === Number(req.params.id));
  if (!task) {
    return res.status(404).json({ error: "Task not found" });
  }
  res.json(task);
});

Best Practices

  • Name resources with plural nouns (/api/tasks, not /api/getTasks) and let the HTTP method carry the verb.
  • Validate input on every write route and answer with 400 plus a clear message, rather than letting bad data reach your data layer.
  • Use the specific status code for the outcome — 201 for creation, 204 for a successful delete with no body, 404 for a missing id — instead of always returning 200.
  • Put your error-handling middleware last, after every route, and never leak stack traces or internal error messages to the client in production.
  • Read configuration like the port from process.env (process.env.PORT ?? 3000) instead of hardcoding it.
  • If you talk to a real database, use parameterized queries (e.g. connection.execute("SELECT * FROM tasks WHERE id = ?", [id])) — never build SQL with string concatenation.
  • Version your API from the start (/api/v1/tasks) so you can introduce breaking changes later without disrupting existing clients.
  • For large collections, always paginate; never return an unbounded array from a GET endpoint.

Practice Exercises

  • Add a PATCH /api/tasks/:id/toggle route that flips a task’s done boolean without requiring the client to send a body.
  • Extend the file-backed version (Example 3) with a DELETE /api/tasks/:id route that reads the file, removes the matching task, writes the file back, and returns 404 if the id does not exist.
  • Add a validation check that rejects a POST body where title is longer than 200 characters, returning 400 with a descriptive error message.

Summary

  • A REST API maps resources and HTTP verbs onto CRUD operations, and communicates outcomes through status codes.
  • Express supplies routing and a middleware chain on top of Node’s http module; express.json() must run before any route reads req.body.
  • Route handlers run synchronously in the request path; only the I/O you explicitly await (files, databases) is offloaded, keeping the event loop free for other requests.
  • In Express 5, a rejected promise from an async handler is automatically forwarded to your error-handling middleware — no manual wrapper needed.
  • Never block the event loop with synchronous fs calls in a request handler, and always return after sending an early response.
  • Validate input, use the right status codes, and keep error details out of client-facing responses.