Express.js REST API Tutorial: Routing, Middleware, and Error Handling the Production Way

Build one small courses API and trace every request through parsing, routing, 404 handling, and a single error path.

KnowledgeGate Team

Exam prep & CS education

Updated 27 Jul 20266 min read

Most Express tutorials show one route that returns JSON and stop there. The real trouble starts when an async database call rejects, a request body arrives empty, or two branches try to send the same response. A reliable API needs a deliberate middleware order and one error path that every failure can reach.

A small courses resource shows all of it: two routes, a JSON parser, a catch-all for unmatched URLs, and one error handler that every failure reaches.

The Express.js request lifecycle

An Express application is an ordered chain of middleware. Each middleware receives req, res, and next. It must either finish the request by sending a response, or call next() so Express can continue along the chain.

Registration order is execution order. That simple rule has practical consequences. If express.json() is registered after a router, the router sees the request first and req.body will be undefined. If a catch-all 404 handler is registered before valid routes, it will intercept requests that should have matched those routes.

Think of the application as a pipeline, not a collection of unrelated functions. Parsing prepares the request, logging observes it, routing handles it, and the final middleware deal with unmatched URLs or errors.

Routing a REST resource

An express.Router() keeps related endpoints together. For a courses resource, two basic routes are:

  • GET /api/courses/:id to fetch one course

  • POST /api/courses to create one course

Express exposes inputs in three places. Path values such as :id are in req.params. Optional URL filters such as ?level=beginner are in req.query. JSON submitted by the client is in req.body, provided the JSON parser ran first.

The response status should describe the result, not merely whether the handler executed. A found resource returns 200. A newly created resource returns 201. Invalid input returns 400. A missing resource returns 404. An unexpected server failure falls back to 500.

A fully worked Express API example

Start with the pieces. An error type carries a status, a wrapper forwards async rejections, and the router holds the two routes:

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

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

const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

const coursesRouter = express.Router();

coursesRouter.get('/:id', asyncHandler(async (req, res) => {
  const course = await Course.findById(req.params.id);
  if (!course) throw new HttpError(404, 'Course not found');
  res.json(course);
}));

coursesRouter.post('/', asyncHandler(async (req, res) => {
  const { title } = req.body;
  if (!title) throw new HttpError(400, 'title is required');
  const course = await Course.create({ title });
  res.status(201).json(course);
}));

function requestLogger(req, res, next) {
  console.log(req.method, req.originalUrl);
  next();
}

function notFound(req, res, next) {
  next(new HttpError(404, `No route for ${req.method} ${req.originalUrl}`));
}

function errorHandler(err, req, res, next) {
  res.status(err.status || 500).json({ error: err.message });
}

Then the wiring, which runs the moment the file loads, so it sits below the definitions:

app.use(express.json());                 // 1 body parser
app.use(requestLogger);                  // 2 logging
app.use('/api/courses', coursesRouter);  // 3 routes
app.use(notFound);                       // 4 catch-all 404
app.use(errorHandler);                   // 5 error handler, 4 args, LAST

Now trace four requests.

  1. GET /api/courses/999 reaches the GET route. Suppose no course has that ID. The route throws HttpError(404, 'Course not found'). asyncHandler converts the rejected promise into next(err), so Express skips ordinary middleware and invokes errorHandler. The response is 404 { "error": "Course not found" }.

  2. POST /api/courses with body {} passes through express.json(), so req.body is an empty object. The validation check throws HttpError(400, 'title is required'). The same error path returns 400 { "error": "title is required" }.

  3. POST /api/courses with body { "title": "DSA" } passes validation. Course.create returns the saved object, and the route responds with 201 { "_id": "...", "title": "DSA" }.

  4. GET /api/students matches no route, so the chain falls through to notFound. That middleware raises HttpError(404, 'No route for GET /api/students') and passes it to next, so the reply is 404 { "error": "No route for GET /api/students" }. A missing record and a missing route both end as 404, but they are raised at different points in the chain and only the second one touches notFound.

Express middleware chain drawn left to right: express.json, requestLogger, coursesRouter, notFound, errorHandler. A normal request travels the chain and the router answers with res.json, while a thrown error leaves coursesRouter through next(err) and lands straight on errorHandler, skipping notFound.

The important design is not the database model. It is that validation failures, missing records, and rejected operations all converge on one response-producing function.

Error handling the production way

Express recognises error middleware by its four parameters: (err, req, res, next). Even when the function does not use next, keep it in the signature. Remove the fourth parameter and Express treats the function as ordinary middleware, so it will not receive forwarded errors.

Register the error handler after every route and after the not-found handler. A normal request should never reach it. An error passed through next(err) jumps past ordinary middleware and lands there.

For Express 4, an async rejection is not forwarded automatically, which is why the example wraps each async route. Promise.resolve(...).catch(next) turns both a thrown error and a rejected promise into the same next(err) call. Express 5 can forward a rejection from a returned async handler, but the explicit wrapper remains common in Express 4 codebases. Know which major version the project uses.

An HttpError carrying a status keeps route code focused. A route throws new HttpError(404, message) or new HttpError(400, message), while the global handler decides the JSON shape. Unknown errors naturally use 500.

Validation and the double-response trap

Validate inputs at the top of a handler, before database work. Throwing a 400 early makes the rest of the handler operate on known-good data.

Another common failure is Cannot set headers after they are sent. It means one branch already called res.json() or res.send(), but execution continued and another branch tried to respond again. This version is broken:

if (!course) res.status(404).json({ error: 'Not found' });
res.json(course);

Return when the response ends that handler:

if (!course) return res.status(404).json({ error: 'Not found' });
return res.json(course);

When using the central error approach, throw instead of sending the first response. Whichever style you choose, one request must receive one response.

Express traps interviewers and code review catch

These defects all follow from the lifecycle above:

  • Error middleware placed before routes cannot catch errors raised later in the chain.

  • A three-parameter "error handler" is silently treated as normal middleware.

  • An Express 4 async rejection that is not forwarded can become an unhandled rejection and leave the client waiting.

  • express.json() placed after the router leaves JSON route handlers without a parsed body.

  • Sending a response and continuing creates a second-response attempt.

A useful interview method is to write the middleware in order first, then trace normal control with next() and error control with next(err). That exposes most mistakes before you run the code.

How interviews test Express routing and errors

Typical questions ask you to order middleware, explain why an async route does not reach the error handler, write a global handler, or select the correct status for a result. There is no exam syllabus to memorise here. The answer comes from Express control flow and HTTP semantics.

For broader preparation, Node.js Interview Questions for Freshers connects routing to runtime behaviour, while JavaScript Interview Questions for Freshers 2026 reinforces the promise mechanics behind async handlers.

The short version and next step

Middleware runs in registration order. Parse before routing, wrap Express 4 async handlers so rejections become next(err), validate early, and let one four-argument error handler registered last produce the failure response. Always return after sending a response.

KnowledgeGate's question bank carries about 600 MERN-stack practice questions. Build the backend with NodeJs + ExpressJs + MongoDB, place it in a wider path with MERN Stack + DSA, or work through the Node.js lessons in the MERN Stack course before choosing your next topic.