Express Introduction

Express is the most widely used web framework for Node.js. It sits on top of Node’s built-in http module and gives you a much friendlier way to define routes, parse request bodies, run reusable middleware, and send responses. Instead of writing large if/else chains inside a single http.createServer callback, Express lets you describe your server as a set of small, composable pieces. This lesson covers what Express is, how its request pipeline actually works, and how to build a real (small) API with it.

Overview / How it works

Under the hood, express() still creates a server that ultimately runs on Node’s http module — Express does not replace it, it wraps it. When you call app.listen(port), Express creates an http.Server instance and hands it a single request handler function. That function is Express’s routing and middleware engine: for every incoming request it walks through the list of middleware and route handlers you registered, in the exact order you registered them, until one of them ends the response.

Two concepts drive everything in Express:

Middleware — a function with the signature (req, res, next) that runs for a request. A middleware function can inspect or modify req/res, end the response itself, or call next() to pass control to the next function in the chain. If no middleware ever ends the response, the request hangs forever — the client just waits, because nothing ever called res.end() (directly or via res.send()/res.json()).

Routing — middleware registered with a specific HTTP method and path, such as app.get("/books/:id", handler). Express matches the incoming method and URL against your registered routes in order, extracts any :param segments into req.params, and parses the query string into req.query.

Because Node’s event loop is single-threaded, every middleware and route handler you write should avoid blocking work (synchronous file reads, heavy CPU loops, synchronous crypto). A blocking call in one request handler stalls every other in-flight request on the server, since there is only one thread processing JavaScript. Express itself is fully async-friendly: route handlers can be async functions, and in Express 5 a rejected promise thrown from an async handler is automatically forwarded to your error-handling middleware — you no longer need to wrap every handler in a manual try/catch just to avoid an unhandled rejection (though catching explicitly is still good practice for control over the response).

Syntax

A minimal Express application follows this general shape:

import express from "express";

const app = express();

app.get("/path", (req, res) => {
  res.send("response body");
});

app.listen(port, () => {
  console.log("server started");
});
Piece Meaning
express() Creates a new Express application instance.
app.get / app.post / app.put / app.delete Registers a route for a specific HTTP method and path.
req The incoming request — req.params, req.query, req.body, req.headers.
res The outgoing response — res.send(), res.json(), res.status(), res.end().
next Function that passes control to the next middleware; call next(err) to jump straight to error-handling middleware.
app.use(middleware) Registers middleware that runs for every request (or every request under a path prefix).
app.listen(port, callback) Starts the underlying HTTP server on the given port.

Install Express with npm before using it:

npm install express

Examples

Example 1: A minimal server

import express from "express";

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

app.get("/", (req, res) => {
  res.send("Hello from Express!");
});

app.listen(PORT, () => {
  console.log(`Server listening on http://localhost:${PORT}`);
});
Server listening on http://localhost:3000
# Visiting http://localhost:3000/ in a browser shows: Hello from Express!

This creates an app, registers one route for GET /, and starts listening. res.send() is Express’s general-purpose response method: it sets a reasonable Content-Type automatically (text/html for a string here) and ends the response for you.

Example 2: Route parameters and query strings

import express from "express";

const app = express();

const books = [
  { id: 1, title: "The Pragmatic Programmer" },
  { id: 2, title: "Clean Code" },
];

app.get("/books", (req, res) => {
  const { author } = req.query;
  res.json({ author: author ?? null, count: books.length, books });
});

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

app.listen(3000);
GET /books/1        -> 200 {"id":1,"title":"The Pragmatic Programmer"}
GET /books/9         -> 404 {"error":"Book not found"}
GET /books?author=x  -> 200 {"author":"x","count":2,"books":[...]}

req.params.id comes from the :id segment in the route path and always arrives as a string, so it is converted with Number() before comparing. req.query holds parsed query-string values. res.json() sets Content-Type: application/json and serializes the value for you — you should never call JSON.stringify yourself and pass it to res.send().

Example 3: Middleware, JSON bodies, and error handling

import express from "express";

const app = express();

app.use(express.json());

app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();
});

const notes = [];

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);
});

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

app.listen(3000, () => {
  console.log("Notes API running on port 3000");
});
POST /notes  { "text": "buy milk" }
-> 201 {"id":1,"text":"buy milk"}

POST /notes  { }
-> 400 {"error":"text is required"}

express.json() is built-in middleware (no separate body-parser package needed anymore) that parses a JSON request body into req.body. The logging middleware runs for every request because it is registered with app.use() with no path, and it calls next() to hand off to the matching route. The last middleware has four parameters — Express recognizes this exact signature as error-handling middleware and only invokes it when something calls next(err) or an async handler’s promise rejects.

How it works step by step

For each incoming request, Express does the following, in order:

1. The underlying http.Server receives the request and hands it to Express’s internal handler.
2. Express walks its middleware/route stack from top to bottom, in the order you called app.use()/app.get()/etc.
3. Each function runs until it either ends the response (res.send, res.json, res.end) or calls next() to continue to the next matching function.
4. Route-specific handlers (app.get("/books/:id", ...)) only run if both the HTTP method and the path pattern match the request.
5. If a handler calls next(err), or an async handler throws/rejects, Express skips straight to the nearest four-argument error-handling middleware, skipping any regular middleware in between.
6. If nothing ever ends the response, the client’s connection simply stays open until it times out — Express never sends a response on its own.

Common Mistakes

Mistake 1: Forgetting to call next()

app.use((req, res, next) => {
  console.log("Request received");
  // forgot to call next()
});

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

Because the logging middleware never calls next() and never ends the response itself, every request hangs forever — the route handler below it is never reached. Always call next() in middleware that is not meant to end the response:

app.use((req, res, next) => {
  console.log("Request received");
  next();
});

Mistake 2: Blocking the event loop with synchronous I/O

import fs from "node:fs";

app.get("/report", (req, res) => {
  const data = fs.readFileSync("./big-report.csv", "utf8");
  res.send(data);
});

readFileSync blocks Node’s single thread until the whole file is read, which stalls every other in-flight request on the server while it runs. Use the promise-based API instead:

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

app.get("/report", async (req, res, next) => {
  try {
    const data = await readFile("./big-report.csv", "utf8");
    res.send(data);
  } catch (err) {
    next(err);
  }
});

Best Practices

  • Mount express.json() (and any body-parsing middleware) before the routes that need req.body.
  • Register your error-handling middleware (the four-argument function) last, after all routes.
  • Prefer async route handlers with try/catch that calls next(err) so failures reach your centralized error handler instead of crashing the process.
  • Never mix blocking, synchronous calls (*Sync functions) inside request handlers — use the fs/promises or callback APIs.
  • Keep route files small and organized with express.Router() once you have more than a handful of routes (covered in a later lesson).
  • Validate req.body/req.params before using them — never trust client input.
  • Read the port from process.env.PORT ?? 3000 rather than hardcoding it, so the app works in different deployment environments.

Practice Exercises

1. Build an Express server with a GET /greet/:name route that responds with Hello, <name>! using the value from req.params.

2. Add a POST /users route that reads { "email": "..." } from a JSON body (using express.json()), rejects the request with a 400 status if email is missing, and otherwise responds with 201 and the created object.

3. Add error-handling middleware to your server that logs the error and returns a JSON { "error": "..." } body with status 500, then trigger it deliberately by calling next(new Error("boom")) from a test route.

Summary

  • Express wraps Node’s http module and adds routing plus a composable middleware pipeline.
  • Middleware runs in registration order and must call next() or end the response, or the request hangs.
  • Route params live in req.params, query strings in req.query, and JSON bodies in req.body (after express.json()).
  • Error-handling middleware is identified by its four-argument (err, req, res, next) signature and should be registered last.
  • Never block the event loop inside a route handler — use async, non-blocking APIs.