Lesson 8 of 20

Introduction to Express.js

What is Express.js?

Express is the framework that almost every Node.js tutorial, job advertisement and existing codebase assumes you know. It is deliberately small: routing, middleware, and a set of helpers bolted onto the request and response objects. That is the whole of it. There is no database layer, no authentication, no validation, and no opinion about where your files should live.

Everything it adds sits directly on top of the http module from the previous lesson. app.listen() hands back an ordinary http.Server; req and res are the same two objects you already met, with extra methods attached. Nothing is hidden or replaced, which is exactly why the errors you saw while writing a raw server show up unchanged in Express.

What you gain is concrete. Route matching on method and path with parameters such as /users/:id. Query strings parsed for you. Body parsing as one line of setup instead of the stream-reading code you wrote by hand. An ordered chain of functions that run before your handlers. A designated place for errors. And small conveniences like res.status(404).json({ error: 'Not found' }). Every item on that list is something you either wrote manually or wished for in lesson 6.

"Unopinionated" is a genuine trade-off rather than marketing. Nothing stops you putting three hundred routes in one file; nothing tells you where models belong. On a first project that freedom feels comfortable, and by the second it is the reason your own code is hard to navigate. Frameworks such as NestJS take the opposite position and impose a structure. Express expects you to bring your own, which is what the layering advice in lesson 3 was preparing you for.

A whole first application is the example below: install it, create an app, describe some routes, listen. One habit to form immediately — res.send() guesses a content type from whatever you hand it, so a string is served as HTML and an object as JSON, while res.json() always sends JSON. In an API, prefer res.json(). Being explicit is what stops a stray string from reaching a frontend that was expecting data.

Example
// Install Express
// npm install express

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

// Define routes
app.get('/', (req, res) => {
  res.send('<h1>Welcome to Express!</h1>');
});

app.get('/about', (req, res) => {
  res.send('<h1>About Page</h1>');
});

app.get('/api/users', (req, res) => {
  res.json([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' }
  ]);
});

// Start the server
app.listen(PORT, () => {
  console.log(`Express server running on http://localhost:${PORT}`);
});
  • express() — create an application instance
  • app.get(path, handler) — handle GET requests to that path
  • app.post, app.put, app.patch, app.delete — the other methods
  • res.send(x) — guesses the content type; a string becomes HTML
  • res.json(obj) — always JSON; the right default for an API
  • app.listen(port, cb) — returns a plain http.Server
  • Express supplies routing and middleware; databases, auth and validation are yours to add
Notes
  • Express does not make your server faster than the raw http module — it makes you faster. Everything it offers is a shortcut for something you could have written, which is a useful thing to be able to say honestly in an interview instead of repeating that a framework is "high performance".

The req and res Objects

Two objects arrive in every handler, and you will spend most of your Express life on them. req describes what the client asked for; res is how you answer. Express keeps everything the http module already gave you and adds the parts you would otherwise write yourself.

Incoming data lives in four places, and mixing them up is a standard early confusion. req.params holds values captured from the path, so a route /users/:id matched against /users/42 puts 42 in req.params.id. req.query holds the query string already parsed, so ?page=2&sort=new becomes an object. req.body holds the parsed body of a POST or PUT. req.headers holds the headers, with every name lowercased.

Two facts about those values save hours of debugging. Everything in req.params and req.query is a string: req.params.id is '42' and not 42, so a strict comparison against a numeric database id fails silently, and page + 1 gives you '21' rather than 3. And req.body is undefined until you add app.use(express.json()) — the first POST handler that reads req.body.title and dies with "cannot read properties of undefined" is a rite of passage.

On the response side, res.status(code) sets the status and returns res again, which is why res.status(201).json(book) reads as one sentence. res.json() serialises and sets the content type. res.set(name, value) adds a header, res.redirect(url) sends a redirect, and res.sendStatus(204) answers with a bare status code and no body.

One thing about req deserves saying before anything else in this course does: everything on it came from outside your program and can be absolutely anything. req.body is not a validated object — it is whatever JSON somebody chose to send, including missing fields, wrong types, a string where you expected a number, and extra keys you never designed for. Every later lesson on validation and injection begins from that single fact, so form the habit now: read from req, check it, then use it.

Example
const express = require('express');
const app = express();

app.use(express.json());   // WITHOUT this line, req.body is undefined

// GET /books/42?format=short
app.get('/books/:id', (req, res) => {
  const id = Number(req.params.id);            // params are STRINGS
  if (!Number.isInteger(id)) {
    return res.status(400).json({ error: 'id must be a number' });
  }

  const format = req.query.format || 'full';   // query values are strings too
  const auth = req.headers.authorization;      // header names are lowercased

  return res.status(200).json({ id, format, authenticated: Boolean(auth) });
});

// POST /books  with a JSON body
app.post('/books', (req, res) => {
  const { title, author } = req.body;          // may be missing, or any type
  if (typeof title !== 'string' || typeof author !== 'string') {
    return res.status(400).json({ error: 'title and author are required' });
  }
  return res.status(201).json({ id: 1, title: title.trim(), author: author.trim() });
});

app.listen(process.env.PORT || 3000);
  • req.params — captured from the path, always strings
  • req.query — the parsed query string, always strings
  • req.body — the parsed body; undefined without a body-parsing middleware
  • req.headers — headers, with lowercased names
  • res.status(code) — returns res, so it chains straight into .json()
  • res.json(obj) — serialise and set the content type; the default for an API
  • res.set(), res.redirect(), res.sendStatus() — the rest of the daily set
Notes
  • A client that posts JSON but forgets the Content-Type: application/json header gets an empty req.body even when express.json() is installed, because the middleware only parses bodies whose content type it recognises. When a POST works from Postman and fails from your own frontend, compare that header first.

Middleware: the Whole Idea

A middleware is an ordinary function with the signature (req, res, next). Express keeps a list of them in the order you registered them, and for each incoming request it walks that list from the top. Each function does its bit and then either calls next() to pass the request along, or sends a response and stops the chain right there.

That is the entire mechanism, and it is worth stating plainly because the word sounds more mysterious than the thing. Your route handlers are middleware too — a handler is just a middleware that happens to be last. That is why app.get('/profile', requireAuth, handler) works at all: it is a two-item list that applies to one route.

Order is everything, and it is the single largest source of silent failures in Express. app.use(express.json()) placed after your routes does nothing for them, because by the time it would run, the route has already answered. Worse, an authentication middleware registered below the routes it was meant to protect never runs for them — no error, no warning, just an open door. Read an Express file from top to bottom; that is genuinely the order things happen in.

Forgetting next() is the other classic. A middleware that neither responds nor calls next() leaves the request suspended forever: the browser spins, the terminal stays silent, nothing has crashed. Any time a route hangs with no error anywhere, look for a middleware above it with a code path that returns without doing either.

Mounting is how you narrow the scope. app.use(fn) applies to everything; app.use('/api', fn) applies only to paths beginning with /api; passing the function into a single route applies it there alone. Choose the narrowest form that does the job — a request logger belongs at the top for everything, an authentication check belongs only on the routes that actually need it.

Example
const express = require('express');
const app = express();

// Built-in middleware: Parse JSON request bodies
app.use(express.json());

// Custom middleware: Log every request
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
  next(); // Pass control to the next middleware
});

// Route handler (also middleware)
app.get('/', (req, res) => {
  res.json({ message: 'Hello World' });
});

// Order is simply the order they are written:
//   1. express.json() parses the body
//   2. the logger logs the request
//   3. the route handler sends the response

// TOO LATE — every route registered above answers without ever seeing this.
// No error is printed. It just never runs for them.
app.use((req, res, next) => {
  console.log('never reached by the routes above');
  next();
});

app.listen(process.env.PORT || 3000);
  • (req, res, next) — the signature every middleware has
  • next() — hand the request on to the next function in the list
  • Respond or call next() — doing neither hangs the request, doing both crashes it
  • app.use(fn) everywhere · app.use('/api', fn) by prefix · app.get(path, fn, handler) per route
  • Registration order is execution order — a middleware below a route never runs for that route
Notes
  • A middleware that calls next() and sends a response is the quiet form of the two-responses bug: the chain continues, a later handler answers as well, and you get ERR_HTTP_HEADERS_SENT pointing at a line that looks entirely innocent. Write return next(); and return res.json(...) so that every path visibly ends where it should.

Splitting app.js from server.js

Two jobs get mixed into one file in almost every beginner project: building the application, and starting it listening on a port. Separating them takes five minutes and pays for itself several times over, which is why every structured Express codebase you will read is arranged this way.

src/app.js creates the Express app, registers middleware and routes, and exports it — without ever calling listen. server.js imports that app, does the startup work that involves the outside world such as connecting to a database and reading configuration, and only then calls app.listen. The app knows how to handle requests; the server file decides when to start accepting them.

The immediate benefit is testability. A test can import app and fire requests at it in memory, with no port opened and nothing to shut down afterwards. That is impossible if importing the file also starts a server, and it is the difference between tests you can run in any order and tests that fight each other for port 3000.

The second benefit is control over startup order. Connect first, listen second. A server that begins accepting requests before its database is ready answers the first arrivals with confusing 500 errors that vanish by the time you investigate. Awaiting the connection makes the sequence explicit, and calling process.exit(1) when it fails is better than a process that stays alive while being unable to serve anything — an alive-but-useless process is exactly what a health check cannot see.

It also prevents one specific bug. If app.listen() lives inside a module that something else requires — a test file, a seed script, a second entry point — the port is claimed at import time, and you get EADDRINUSE from code that never meant to start a server. Only the entry point should listen.

Example
// src/app.js — builds the application; never listens
const express = require('express');
const bookRoutes = require('./routes/books');

const app = express();

app.use(express.json());
app.use('/api/books', bookRoutes);

app.get('/health', (req, res) => res.json({ status: 'ok' }));

module.exports = app;      // note: no app.listen() anywhere in this file


// server.js — the only file that starts anything
const app = require('./src/app');
const connectDb = require('./src/db');

const PORT = process.env.PORT || 3000;

async function start() {
  try {
    await connectDb();                  // fail BEFORE accepting traffic
    app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
  } catch (err) {
    console.error('Startup failed:', err.message);
    process.exit(1);                    // crash loudly rather than limp on
  }
}

start();
  • app.js — middleware and routes, exports the app, never calls listen
  • server.js — configuration, database connection, then app.listen
  • Tests import app directly, so no port is opened and nothing needs cleaning up
  • Connect to the database before listening, or the first requests fail confusingly
  • process.exit(1) on a failed start — a process that cannot work should not stay up
Notes
  • This split is also what lets you run something else on the same port later. Because app is just a request handler, you can pass it to http.createServer(app) and attach a WebSocket library to that server — which you cannot do if the app file has already called listen on your behalf.

The Mistakes Everyone Makes in Week One

req.body is undefined is the most common of all, and it has three causes. express.json() was never added; or it was added below the route, so it never runs for it; or the client did not send a Content-Type: application/json header, so the middleware declined to parse a body it did not recognise. Check them in that order and the answer appears within a minute.

Route order with parameters catches almost everybody once. Express uses the first route that matches, so if app.get('/users/:id') is registered above app.get('/users/new'), a request for /users/new matches the first one with req.params.id set to the string 'new'. Your database is then asked for a user whose id is "new", and the error you get says nothing about routing. Specific paths go above parametrised ones.

A missing return before a response gives you ERR_HTTP_HEADERS_SENT, exactly as it did with the raw http module — Express changed the convenience, not the protocol. If you formed the habit of writing return res.status(...) in the previous lesson, this one never happens to you.

Errors thrown inside an async handler behave differently depending on which major version of Express you are working with, and older versions do not forward them to your error handler at all: the request hangs while Node logs an unhandled promise rejection somewhere you are not looking. Until you have checked which version the project uses, wrap async handlers in try/catch and call next(err). Lesson 17 shows how to do that once rather than in every route.

Two smaller ones that each cost an afternoon. Node does not reload your code when you save, so a change that "did nothing" is very often a server still running the previous version — start it with node --watch and the doubt disappears permanently. And a route that works when you open it in the browser but fails from your own frontend is usually not broken at all; the browser is refusing the response because of CORS, which is a rule browsers enforce rather than an error your server produced. Lesson 10 deals with it properly.

  • req.body undefined — express.json() missing, placed below the route, or no Content-Type sent
  • Put specific routes above parametrised ones: /users/new before /users/:id
  • A middleware registered after a route never runs for that route — no warning is printed
  • Missing return before a response — ERR_HTTP_HEADERS_SENT
  • An async handler throws and nothing happens — use try/catch and next(err) for now
  • Nothing changed after saving — the old process is still running; use node --watch
Notes
  • When an Express route behaves strangely, put a console.log on the very first line of the handler. If it never prints, the problem is above the handler — routing, middleware order, or a middleware that forgot next(). If it prints, the problem is inside. That one check halves the search space before you have read a single line of your own logic.
Ask AI