Module: Nodejs
Nodejs·126·4 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

You will learn 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.

Express is a framework/library, not Node itself

Native Node provides:

text
HTTP server
IncomingMessage
ServerResponse
streams
URL APIs

Express adds conveniences:

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

The previous lessons should make these additions understandable.

Install

bash
npm install express

Use a current supported Express 5 release.

First app

js
import express from 'express';

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

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

  return app;
}

Startup:

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.

Middleware model

Mental model:

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

Middleware order is configuration.

This:

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

differs from:

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

The second may allow routes before auth middleware.

Basic middleware

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

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

  next();
}

Attach:

js
app.use(requestId);

res.locals

Useful for request-scoped data passed between middleware/handlers:

js
res.locals.user
res.locals.requestId

Avoid storing request state on global variables.

For deeply nested diagnostics, AsyncLocalStorage can complement this.

JSON parsing

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

Never accept unlimited body by default for public APIs.

Parser errors should map to controlled JSON errors.

URL-encoded forms

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

Use only if endpoint needs this content type.

Do not enable every parser globally without a reason.

Route params

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

Validate format.

Path params are user input.

Query params

js
const status = req.query.status;

Express query values can have shapes depending parser/config.

Do not blindly pass:

js
req.query

into MongoDB/ORM query.

Validate/normalize to known primitive object.

Body

js
const input = req.body;

Never:

js
model.create(req.body);

without schema/allowlist.

Mass assignment and operator injection risks remain.

Router

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:

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

Dependency injection keeps router testable.

Thin controllers

Avoid:

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

Prefer:

text
HTTP normalization
→ service
→ HTTP response mapping

Async error handling in Express 5

Express 5 supports forwarding rejected Promises from async route handlers to error handling.

Example:

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.

Do not copy legacy wrapper boilerplate from Express 4 tutorials unless your target framework/version requires it.

Error middleware

Signature has four arguments:

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

Example:

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 after routes:

js
app.use(errorHandler);

404

Express does not automatically throw 404 because no route matched.

Add fallback:

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

Then error middleware.

Parser errors

Malformed JSON may reach error middleware.

Map syntax/parser errors safely.

Do not expose parser stack.

next

Middleware:

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

  next();
}

Do not call next() after sending a response.

That can cause:

text
Cannot set headers after they are sent

Response return style

This is often clearer:

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

to stop handler flow.

Returning the Express response object is not semantically required for Express; it is control-flow clarity.

app.param

Express can centralize param preprocessing, but do not hide heavy DB lookups unexpectedly.

Use where it improves clarity and testability.

Route ordering

More specific routes before broad parameter routes if patterns overlap.

Example:

text
/tasks/stats
/tasks/:id

Ensure /stats is not accidentally interpreted as ID depending router definitions.

Static files

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

For production, CDN/reverse proxy may be better for static assets.

Understand cache/security options.

Do not expose source/config directories.

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.

Know server-rendered templates remain valid for many applications.

Trust proxy

Behind reverse proxy:

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

This affects:

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

Do not set blindly to true on an untrusted network topology.

Configure according to deployment.

Testing app

Use a test HTTP client/library against app/server instance.

Keep:

js
createApp()

side-effect free from actual listen() so tests can compose it.

Do not import a module that starts port 3000 automatically.

Framework alternatives

Node roadmap lists frameworks such as:

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

This course uses Express because uploaded module began there and it is widely recognized.

The Node concepts transfer.

Framework selection criteria:

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

Common 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.

Exercises

  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.

Mastery checklist

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.

Official references