FullStack Course LogoFullStack Course
Module: Nodejs
Nodejs·126·14 MIN READ

126: Express as a Node Framework — App, Router, Middleware, Routing, and Error Handling

TOPICS COVERED: Express as a Node Framework — App, Router, Middleware, Routing, and Error Handling

Learning objectives

By the end of this lesson, you should be able to:

  • explain what Express adds on top of Node HTTP;
  • create an Express 5 application;
  • separate app composition from process startup;
  • define routes and routers;
  • understand middleware ordering;
  • parse request bodies with explicit limits;
  • use params/query/body safely;
  • write async handlers and centralized error middleware;
  • understand req, res, and res.locals;
  • avoid framework-specific coupling in services;
  • test the Express app without opening a real production port.

These are practical boundaries, not just API names. You should be able to look at an Express request, identify which middleware and route handled it, and decide where validation, business logic, and error mapping belong.

Express is a framework/library, not Node itself

When an Express application behaves unexpectedly, the useful first distinction is between Node's HTTP layer and the framework layered on top of it. Native Node provides the lower-level pieces:

text
HTTP server
IncomingMessage
ServerResponse
streams
URL APIs

Those primitives let you receive a request, inspect its method and URL, read its stream body, and write a response. They do not, by themselves, give you a conventional router, a middleware pipeline, or helpers such as res.json().

Express adds conveniences around those primitives:

text
routing
middleware pipeline
body parsing helpers
request/response helpers
router composition
error middleware convention

Express is therefore not a replacement runtime and not a different version of Node HTTP. It is a library and framework that organizes the request/response work Node already makes possible. The previous lessons should make these additions understandable: Express still receives HTTP input, reads streams, and ultimately writes a response; it simply gives those operations a more structured programming model.

Install

Install Express as an application dependency:

bash
npm install express

Use a current supported Express 5 release. The version matters here because Express 5's async error behavior differs from many Express 4 examples still circulating online.

First app

Start with an app factory rather than starting the server as soon as the module is imported:

js
import express from 'express';

export function createApp() {
  const app = express();

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

  return app;
}

express() creates the application object. app.get() registers a handler for GET /health, and res.json() serializes the response and sets the appropriate response behavior for JSON. The req argument is the incoming request, while res is the outgoing response.

Startup belongs in a separate entry point:

js
import { createApp } from './app.js';

const app = createApp();

const server = app.listen(3000, () => {
  console.log('listening on 3000');
});

Separating createApp() from startup improves testing and lifecycle control. A test can create the app and send requests to it without importing a module that immediately binds port 3000. The process entry point can also own concerns such as shutdown, listening errors, and environment-specific configuration instead of hiding them inside route composition.

Middleware model

Middleware is code that participates in request processing. A useful mental model is a sequence through which a request moves:

text
request
↓
middleware A
↓
middleware B
↓
route handler
↓
error middleware if failure
↓
response

The diagram is simplified: middleware can end the response, skip to another route, or pass control onward with next(). A handler may also fail and send control to error middleware. The key point is that the order in which middleware is registered is part of the application's behavior.

Middleware order is configuration. For example:

js
app.use(auth);
app.use('/admin', adminRouter);

differs from:

js
app.use('/admin', adminRouter);
app.use(auth);

In the first arrangement, auth gets a chance to process the request before the admin router. In the second, the router is encountered first. Depending on its route definitions and whether it ends the request, that arrangement may allow routes before auth middleware. This is why a security middleware's presence is not enough; its position must also be correct.

Basic middleware

A normal middleware function receives the request, response, and a callback used to continue processing:

js
function requestId(req, res, next) {
  const id = crypto.randomUUID();

  res.locals.requestId = id;
  res.setHeader('x-request-id', id);

  next();
}

This middleware generates a value for the current request, stores it in res.locals for later handlers, and exposes it in the response header. The example assumes the crypto API has been made available in the module. A request ID is useful when correlating an HTTP response with server logs, but it is not an authentication credential.

Attach middleware before the routes that need it:

js
app.use(requestId);

If middleware neither sends a response nor calls next(), the request will remain in that part of the pipeline. When debugging a hanging request, check both of those control-flow outcomes and the order in which the functions were registered.

res.locals

res.locals is useful for request-scoped data passed between middleware and handlers:

js
res.locals.user
res.locals.requestId

It belongs to the response object, but the data is intended to live for the lifetime of the current request. Authentication middleware might place a resolved user there; request-ID middleware might place the correlation ID there. Later code can read the value without relying on a global variable.

Avoid storing request state on global variables. Globals allow concurrent requests to overwrite one another and make tests order-dependent. For deeply nested diagnostics, AsyncLocalStorage can complement res.locals, especially when code several layers below the handler needs access to request context. It does not remove the need to understand the request lifecycle or to avoid leaking one request's data into another.

JSON parsing

Enable JSON parsing with an explicit size limit:

js
app.use(
  express.json({
    limit: '256kb',
  }),
);

After this middleware has run, a valid JSON request body is available as req.body. The limit is part of the API's resource boundary: it protects the process from spending unbounded memory and CPU on an input that the endpoint does not need.

Never accept unlimited body by default for public APIs. Choose limits based on the actual contract, and consider whether different endpoints need different limits. Parser errors should map to controlled JSON errors so clients receive a stable response rather than an internal stack trace or a framework-specific accidental format.

URL-encoded forms

For URL-encoded form submissions, configure the parser explicitly:

js
app.use(
  express.urlencoded({
    extended: false,
    limit: '64kb',
  }),
);

Use this parser only if an endpoint needs this content type. extended: false keeps the example's parsing model simple; the right setting depends on the request format your API has chosen and the shapes it is willing to accept.

Do not enable every parser globally without a reason. Every parser expands the set of input the application accepts and creates another place where size, syntax, and error behavior must be considered.

Route params

Route parameters are values captured from the path:

js
app.get('/tasks/:taskId', async (req, res) => {
  const taskId = req.params.taskId;
  ...
});

For a request such as /tasks/42, req.params.taskId contains the captured value. It is still user input, even though it appears in a URL path rather than in a body. Validate its format before passing it to a service or database layer. For example, if the service expects a UUID or an integer, reject values that do not meet that contract instead of letting lower layers interpret arbitrary text.

Query params

Query parameters are also request input:

js
const status = req.query.status;

Express query values can have shapes depending on the parser and configuration. A value may not be the primitive string your service expects, particularly when repeated keys or nested query syntax are accepted.

Do not blindly pass:

js
req.query

into MongoDB/ORM query.

Instead, validate and normalize the fields the endpoint actually supports into a known primitive object. This is both an API-contract concern and a security boundary: an arbitrary query object can expose operators, unexpected filters, or query behavior that the route never intended to permit.

Body

After JSON or form parsing, the request body is available through:

js
const input = req.body;

The parsed value is not automatically trustworthy or shaped like the model used by the database. Never do this:

js
model.create(req.body);

without schema/allowlist.

Build an input object from the fields the endpoint permits, validate each field, and pass that object to the service. Mass assignment and operator injection risks remain even when the parser successfully produced a JavaScript object. Parsing answers "can this input be represented?"; validation answers "does it satisfy this endpoint's contract?"

Router

A router lets a related group of routes be composed separately from the main application. It can receive its dependencies explicitly:

js
import { Router } from 'express';

export function createTaskRouter({ taskService }) {
  const router = Router();

  router.get('/', async (req, res) => {
    const result = await taskService.list(...);

    res.json({
      data: result,
    });
  });

  router.post('/', async (req, res) => {
    const task = await taskService.create(...);

    res.status(201).json({
      data: {
        task,
      },
    });
  });

  return router;
}

Mount it under the resource path:

js
app.use(
  '/tasks',
  createTaskRouter({ taskService }),
);

The router's / routes now correspond to /tasks. Dependency injection keeps the router testable: a test can provide a fake taskService without requiring the router module to create a database connection or discover a global service. The service also remains independent of Express's req and res objects.

Thin controllers

An easy way to create a difficult-to-test application is to put every concern in a route callback:

js
router.post('/', async (req, res) => {
  // 200 lines:
  // validate
  // authorize
  // DB queries
  // email
  // payment
  // response
});

Prefer a controller that translates between HTTP and application code:

text
HTTP normalization
→ service
→ HTTP response mapping

That does not mean controllers must contain no decisions. They may authenticate or authorize the request, validate and normalize HTTP input, choose a status code, and map a known application error to an HTTP response. The persistence, payment, and domain workflow should not depend on Express-specific objects. Thin boundaries make failures easier to test and keep a later transport change from rewriting the service layer.

Async error handling in Express 5

Express 5 supports forwarding rejected Promises from async route handlers to error handling. This means an async handler can use ordinary await flow:

js
router.get('/:id', async (req, res) => {
  const task = await service.get(req.params.id);

  res.json({
    data: {
      task,
    },
  });
});

If service.get rejects, Express 5 can route the failure to error middleware. The rejection still needs to be handled by a correctly registered error handler, and the service error still needs a deliberate HTTP mapping. Express forwarding is not a substitute for validation or error classification.

Do not copy legacy wrapper boilerplate from Express 4 tutorials unless your target framework/version requires it. First confirm the Express version and its documented behavior; unnecessary wrappers make handlers harder to read and can obscure the actual control flow.

Error middleware

Error middleware is distinguished by a four-argument signature:

js
function errorHandler(error, req, res, next) {
  ...
}

The four parameters are significant, including next even when the handler usually sends the response. If headers have already been sent, the error may need to be passed onward so Express can complete the connection correctly.

Here is a centralized handler that exposes a controlled validation error and hides unexpected implementation details:

js
function errorHandler(error, req, res, next) {
  if (res.headersSent) {
    return next(error);
  }

  if (error instanceof ValidationError) {
    return res.status(422).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Check the request.',
        fields: error.fields,
      },
    });
  }

  logger.error(
    {
      error,
      requestId: res.locals.requestId,
    },
    'unexpected request failure',
  );

  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'The request could not be completed.',
    },
  });
}

Register it after the routes:

js
app.use(errorHandler);

The placement allows route and earlier middleware failures to reach it. Log enough context to investigate the failure, such as the request ID, but do not send stack traces, database details, tokens, or other internal data to an untrusted client.

404

Express does not automatically throw 404 because no route matched. A request can simply fall through the registered middleware stack, so add an explicit fallback:

js
app.use((req, res) => {
  res.status(404).json({
    error: {
      code: 'ROUTE_NOT_FOUND',
      message: 'Route not found.',
    },
  });
});

Then error middleware.

The order matters: the 404 fallback should come after the application routes, but the error handler should follow the fallback. A route that throws is not the same event as a route that does not exist; the former should reach error middleware, while the latter should receive the deliberate not-found response.

Parser errors

Malformed JSON may reach error middleware. That failure happens while parsing the request, before the route handler can use req.body.

Map syntax/parser errors safely to the API's controlled error format. Do not expose the parser stack. A client needs to know that its payload was invalid, not which internal module and filesystem location attempted to parse it.

next

Normal middleware calls next() when it has finished its work and wants the next matching middleware or route to run:

js
function auth(req, res, next) {
  if (!token) {
    res.status(401).json(...);
    return;
  }

  next();
}

When authentication fails, the middleware sends the response and returns. Do not call next() after sending a response. Doing both can allow later code to run and can produce:

text
Cannot set headers after they are sent

That message usually means two parts of the pipeline attempted to complete the same response. When diagnosing it, inspect every branch for a missing return, a duplicate next(), or a handler that continues after res.json(), res.send(), or another response-ending call.

Response return style

This style is often clearer:

js
return res.status(404).json(...);

It stops the current handler's JavaScript flow after the response is initiated. Returning the Express response object is not semantically required for Express; it is control-flow clarity. The important behavior is that no later branch tries to send another response or calls next() incorrectly.

app.param

Express can centralize param preprocessing, but do not hide heavy DB lookups unexpectedly. A parameter hook that silently performs a database query changes the cost of every matching route and can make the route's dependencies difficult to see while debugging.

Use app.param where it improves clarity and testability, such as consistent lightweight parsing or validation. Keep expensive or business-specific work visible when that visibility helps a reader understand the request's behavior.

Route ordering

When patterns overlap, define more specific routes before broad parameter routes.

For example:

text
/tasks/stats
/tasks/:id

Ensure /stats is not accidentally interpreted as ID depending on the router definitions. If the parameter route matches first, a request intended for the stats operation may be sent to a handler that tries to load a task whose ID is literally stats. Route order is therefore another form of configuration that deserves a focused test.

Static files

Express can serve a directory through its static middleware:

js
app.use(
  '/public',
  express.static(publicDirectory),
);

For production, a CDN/reverse proxy may be better for static assets because it can handle caching and delivery without making the application process serve every byte. Either way, understand the cache and security options you are choosing.

Do not expose source/config directories. Static serving should point at an intentional public directory, not at the project root or a directory containing environment files, source maps with sensitive information, private keys, or deployment configuration.

Views/template engines

Express supports template engines such as:

text
EJS
Pug

Node roadmap includes them, but this course's main architecture is JSON APIs because frontend React is already covered. That is a course-scope choice, not a claim that server-rendered templates are obsolete. Server-rendered templates remain valid for many applications, and the same concerns about input handling, response errors, caching, and security still apply.

Trust proxy

When an application runs behind a reverse proxy, Express can be configured with:

js
app.set('trust proxy', ...);

This affects values and behaviors related to:

  • client IP;
  • protocol;
  • secure cookies;
  • forwarded headers.

Do not set it blindly to true on an untrusted network topology. The application may then trust forwarded information that an external client was able to forge. Configure it according to the actual deployment: which proxies exist, which networks are trusted, and how many or which proxy hops the application should honor.

Testing app

Use a test HTTP client/library against the app/server instance. The test should exercise the same routing and middleware composition that production uses, while avoiding a fixed production port when the test does not need one.

Keep:

js
createApp()

side-effect free from actual listen() so tests can compose it. The startup module can call listen() when the process is launched, but importing the app for a test should not bind port 3000 or start unrelated resources.

Do not import a module that starts port 3000 automatically. If a test hangs, fails because a port is occupied, or leaves an open handle behind, this separation is one of the first boundaries to inspect.

Framework alternatives

Node roadmap lists frameworks such as:

  • Express;
  • Fastify;
  • NestJS;
  • Hono.

This course uses Express because the uploaded module began there and it is widely recognized. The choice is not a claim that Express is always the best option. The Node concepts transfer: HTTP requests and responses, routing, middleware or hooks, validation, error boundaries, and application lifecycle remain concerns whichever framework supplies the conventions.

Framework selection criteria include:

  • performance;
  • validation;
  • plugin ecosystem;
  • TypeScript model;
  • architecture conventions;
  • team experience;
  • maintenance/security.

Evaluate those criteria against the application's constraints rather than selecting a framework from a benchmark number alone. A framework's conventions can improve consistency, but they also become part of the team's maintenance and upgrade responsibilities.

Common mistakes

The recurring mistakes are mostly boundary and ordering mistakes:

  • treating Express as Node;
  • giant route handlers;
  • no body limit;
  • req.body persisted directly;
  • error middleware before routes;
  • legacy Express 4 async wrappers copied into Express 5 blindly;
  • next() after response;
  • no 404 handler;
  • trust proxy misconfigured;
  • app starts listening during import;
  • database connection hidden in router import.

When debugging one of these, first identify which boundary is wrong: framework versus runtime, HTTP normalization versus service logic, parser versus validator, route versus fallback, or app composition versus process startup. That usually narrows the investigation more quickly than changing middleware at random.

Exercises

Work through these in order. The early exercises establish the composition boundary; the later ones deliberately create failures and ordering changes so you can observe the resulting behavior.

  1. Convert native Task API to Express while preserving contract tests.
  2. Separate createApp and server.js.
  3. Add JSON body limit.
  4. Create Task Router with dependency injection.
  5. Add request ID middleware.
  6. Add 404 and centralized error middleware.
  7. Force malformed JSON.
  8. Reorder middleware and observe security effect.
  9. Test route without binding a fixed production port.
  10. Compare Express and native HTTP responsibilities.

For each exercise, inspect the HTTP status, response body, and server logs rather than checking only whether the request returned. In particular, malformed input, an unknown route, and an unexpected service failure should be distinguishable outcomes.

Mastery checklist

You should be able to explain:

  • what Express adds;
  • app/router;
  • middleware ordering;
  • body parsers;
  • params/query/body;
  • res.locals;
  • async Express 5 errors;
  • 404/error middleware;
  • trust proxy;
  • thin controller/service boundaries.

You should also be able to use those ideas while tracing a request: identify the middleware that ran, determine whether the route matched, locate the validation or service failure, and explain why the final status and response shape were produced.

Official references

Keep these references nearby when checking framework-version behavior or deployment details:

Reader page: /nodejs/lesson/126/express-as-a-node-framework-app-router-middleware-routing-and-error-handling