Skip to content

03 · Building a REST API with Express

🎥 Video walkthrough

Express is the most widely used Node.js web framework — a thin layer over Node's built-in http module that adds routing, middleware, and convenient request/response helpers. This module builds a small REST API from scratch.

Installing and starting a server

npm install express
// server.js
import express from "express";

const app = express();
const PORT = 3000;

app.get("/", (req, res) => {
  res.send("API is running");
});

app.listen(PORT, () => {
  console.log(`Server listening on http://localhost:${PORT}`);
});
node server.js
# Server listening on http://localhost:3000

Routing

Routes match an HTTP method and a URL path to a handler function. Route parameters (:id) capture dynamic segments of the URL.

import express from "express";

const app = express();
app.use(express.json()); // parses JSON request bodies into req.body

const notes = [
  { id: 1, text: "Buy milk" },
  { id: 2, text: "Walk the dog" },
];

app.get("/notes", (req, res) => {
  res.json(notes); // sends JSON + sets Content-Type automatically
});

app.get("/notes/:id", (req, res) => {
  const note = notes.find((n) => n.id === Number(req.params.id));
  if (!note) {
    return res.status(404).json({ error: "note not found" });
  }
  res.json(note);
});

app.post("/notes", (req, res) => {
  const { text } = req.body;
  if (!text) {
    return res.status(400).json({ error: "text is required" });
  }
  const note = { id: notes.length + 1, text };
  notes.push(note);
  res.status(201).json(note); // 201 Created
});

app.listen(3000);
curl http://localhost:3000/notes
# [{"id":1,"text":"Buy milk"},{"id":2,"text":"Walk the dog"}]

curl -X POST http://localhost:3000/notes \
  -H "Content-Type: application/json" \
  -d '{"text":"Read a book"}'
# {"id":3,"text":"Read a book"}

Query strings and request data

// GET /search?q=milk&limit=5
app.get("/search", (req, res) => {
  const { q, limit = "10" } = req.query; // req.query holds parsed query-string params
  res.json({
    query: q,
    limit: Number(limit),
    results: notes.filter((n) => n.text.toLowerCase().includes((q ?? "").toLowerCase())),
  });
});
Request data Accessed via Example
Route params req.params /notes/:id -> req.params.id
Query string req.query ?q=milk -> req.query.q
JSON body req.body (needs express.json()) POST payload
Headers req.headers req.headers["authorization"]

Middleware

Middleware functions run between the request arriving and the route handler — they can inspect/modify req/res, or short-circuit the request. Calling next() passes control to the next middleware or route handler; not calling it leaves the request hanging.

// A simple request logger, applied to every route
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
  next(); // must be called or the request never reaches its route
});

// Middleware scoped to one route: a naive API-key check
function requireApiKey(req, res, next) {
  if (req.headers["x-api-key"] !== "secret123") {
    return res.status(401).json({ error: "invalid or missing API key" });
  }
  next();
}

app.delete("/notes/:id", requireApiKey, (req, res) => {
  const index = notes.findIndex((n) => n.id === Number(req.params.id));
  if (index === -1) return res.status(404).json({ error: "note not found" });
  notes.splice(index, 1);
  res.status(204).end(); // 204 No Content — success, nothing to return
});

Express Router — organizing routes across files

Real APIs split routes by resource instead of putting everything in one file. express.Router() creates a mini standalone router that the main app mounts under a path prefix.

// routes/notes.js
import { Router } from "express";

const router = Router();
const notes = [{ id: 1, text: "Buy milk" }];

router.get("/", (req, res) => res.json(notes));

router.get("/:id", (req, res) => {
  const note = notes.find((n) => n.id === Number(req.params.id));
  if (!note) return res.status(404).json({ error: "not found" });
  res.json(note);
});

export default router;
// server.js
import express from "express";
import notesRouter from "./routes/notes.js";

const app = express();
app.use(express.json());
app.use("/notes", notesRouter); // all routes above are now prefixed with /notes

app.listen(3000);

Error-handling middleware

Express recognizes an error-handling middleware by its four parameters (err, req, res, next). Place it last, after all routes, to catch anything thrown or passed to next(error).

app.get("/boom", () => {
  throw new Error("something went wrong");
});

// Error-handling middleware — must be defined AFTER all routes
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: "internal server error" });
});

HTTP status codes cheat sheet

Code Meaning Typical use
200 OK successful GET/PUT
201 Created successful POST that creates a resource
204 No Content successful DELETE, nothing to return
400 Bad Request validation failure
401 Unauthorized missing/invalid credentials
404 Not Found resource doesn't exist
500 Internal Server Error unhandled exception

How It Actually Works

Express's entire request-handling model is a middleware chain: each app.use or route handler is stored in an internal array along with the path pattern it matches, and dispatching a request walks that array in registration order, calling each matching middleware with a next function that, when invoked, resumes the walk to the next entry. This is just a manually-managed continuation-passing pattern — there's no magic scheduling involved, next() is a plain synchronous function call that recurses into the next middleware, which is exactly why forgetting to call next() (or res.end()) in a middleware hangs the request forever: the walk simply never continues.

Because Node handles one request per "tick" of JS execution but the underlying HTTP server accepts many connections concurrently via the OS's async I/O, a single Express process genuinely serves many simultaneous requests — but only if your handlers yield control back to the event loop between blocking-ish work (via await, callbacks, or setImmediate). A synchronous CPU-heavy handler (e.g., hashing a huge payload in a tight loop) blocks the entire process, delaying every other in-flight request's processing, because there is exactly one JS thread regardless of how many TCP connections libuv is juggling underneath it.

Exercise

Build an Express API for a /tasks resource with an in-memory array: GET /tasks (list all), GET /tasks/:id, POST /tasks (validate that title is a non-empty string, respond 400 otherwise), PATCH /tasks/:id to toggle a done boolean, and DELETE /tasks/:id. Add a logging middleware and a final error-handling middleware that catches anything unexpected and responds with a 500 JSON error instead of crashing the server.