126: 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, andres.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:
HTTP server IncomingMessage ServerResponse streams URL APIs
Express adds conveniences:
routing middleware pipeline body parsing helpers request/response helpers router composition error middleware convention
The previous lessons should make these additions understandable.
Install
npm install express
Use a current supported Express 5 release.
First app
import express from 'express';
export function createApp() {
const app = express();
app.get('/health', (req, res) => {
res.json({
ok: true,
});
});
return app;
}
Startup:
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:
request ↓ middleware A ↓ middleware B ↓ route handler ↓ error middleware if failure ↓ response
Middleware order is configuration.
This:
app.use(auth);
app.use('/admin', adminRouter);
differs from:
app.use('/admin', adminRouter);
app.use(auth);
The second may allow routes before auth middleware.
Basic middleware
function requestId(req, res, next) {
const id = crypto.randomUUID();
res.locals.requestId = id;
res.setHeader('x-request-id', id);
next();
}
Attach:
app.use(requestId);
res.locals
Useful for request-scoped data passed between middleware/handlers:
res.locals.user
res.locals.requestId
Avoid storing request state on global variables.
For deeply nested diagnostics, AsyncLocalStorage can complement this.
JSON parsing
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
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
app.get('/tasks/:taskId', async (req, res) => {
const taskId = req.params.taskId;
...
});
Validate format.
Path params are user input.
Query params
const status = req.query.status;
Express query values can have shapes depending parser/config.
Do not blindly pass:
req.query
into MongoDB/ORM query.
Validate/normalize to known primitive object.
Body
const input = req.body;
Never:
model.create(req.body);
without schema/allowlist.
Mass assignment and operator injection risks remain.
Router
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:
app.use(
'/tasks',
createTaskRouter({ taskService }),
);
Dependency injection keeps router testable.
Thin controllers
Avoid:
router.post('/', async (req, res) => {
// 200 lines:
// validate
// authorize
// DB queries
// email
// payment
// response
});
Prefer:
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:
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:
function errorHandler(error, req, res, next) {
...
}
Example:
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:
app.use(errorHandler);
404
Express does not automatically throw 404 because no route matched.
Add fallback:
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:
function auth(req, res, next) {
if (!token) {
res.status(401).json(...);
return;
}
next();
}
Do not call next() after sending a response.
That can cause:
Cannot set headers after they are sent
Response return style
This is often clearer:
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:
/tasks/stats /tasks/:id
Ensure /stats is not accidentally interpreted as ID depending router definitions.
Static files
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:
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:
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:
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.bodypersisted 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
- Convert native Task API to Express while preserving contract tests.
- Separate
createAppandserver.js. - Add JSON body limit.
- Create Task Router with dependency injection.
- Add request ID middleware.
- Add 404 and centralized error middleware.
- Force malformed JSON.
- Reorder middleware and observe security effect.
- Test route without binding a fixed production port.
- 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.
