Chapter 15: Frameworks and Middleware
Node’s HTTP core is intentionally small, which leaves room for frameworks that add routing, middleware, and helpful patterns. This chapter compares popular servers, explains how the request lifecycle works, and shows practical techniques for validation, logging, errors, templates, and static files. The examples use TypeScript so that types guide your design and catch mistakes early.
Express, Fastify, and Koa compared
Express, Fastify, and Koa each provide a thin layer over Node networking with different opinions about speed, extensibility, and ergonomics. The right choice depends on your needs around plugins, performance, and how you want to model middleware.
| Framework | Philosophy | Middleware model | Routing style | Types | Logging | Validation |
| Express | Mature and minimal with a huge ecosystem | app.use stack with req, res, next |
Methods like app.get, app.post |
Community types via @types/express |
External middlewares like morgan |
Libraries like zod, joi |
| Fastify | Speed and developer experience with schema first design | Hook system; encapsulated plugins and decorators | fastify.route or fastify.get |
First class TS helpers | Built in via pino |
Built in JSON Schema with fast validators |
| Koa | Tiny core with composable async functions | Onion style async (ctx, next) |
Router as a separate package | Community types via @types/koa |
External middlewares | Libraries or router layer |
When to prefer each
Choose Express when you want maximal compatibility and a stable ecosystem. Choose Fastify when performance, JSON Schema, and plugin encapsulation matter. Choose Koa when you prefer small building blocks and the onion model of composition.
Project setup in TypeScript
All three work well with TypeScript once you add types and compiler options. Enable esModuleInterop if a package expects default imports, and target a modern runtime that matches your Node version.
// Example common tsconfig bits
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
}
}
Routing, request lifecycle, and middleware
Frameworks deliver a pipeline that receives a request, transforms it through middleware, then produces a response. Ordering is significant since earlier functions prepare context for later ones. Understanding the chain lets you place logging, parsing, and guards in predictable spots.
The pipeline
Each request flows through a stack. In Express it is the app.use chain. In Koa it is nested await next() calls. In Fastify it is hooks like onRequest, preHandler, and route handlers. Place cross cutting concerns early, keep handlers focused, and return quickly on failures.
Writing app.use middleware
Express middleware reads or augments req and res, then calls next. Keep it pure and side effect aware so that sequencing remains clear.
// express-middleware.ts
import express, { Request, Response, NextFunction } from "express";
const app = express();
app.use((req: Request, _res: Response, next: NextFunction) => {
req.headers["x-start"] = Date.now().toString();
next();
});
app.get("/ping", (_req, res) => {
res.json({ ok: true });
});
app.listen(3000);
Koa’s onion model
Koa composes async functions so that code before await next() runs on the way in, and code after runs on the way out. This makes timing and error wrapping natural.
// koa-onion.ts
import Koa from "koa";
const app = new Koa();
app.use(async (ctx, next) => {
const t = Date.now();
await next();
ctx.set("x-time", String(Date.now() - t));
});
app.use(async ctx => {
ctx.body = { ok: true };
});
app.listen(3000);
Fastify hooks and encapsulation
Fastify encourages plugins that register routes and hooks with local scope. Encapsulation keeps concerns modular and reduces accidental cross talk between features.
// fastify-hooks.ts
import Fastify from "fastify";
const app = Fastify({ logger: true });
app.addHook("onRequest", async (req, _res) => {
req.headers["x-start"] = Date.now().toString();
});
app.get("/ping", async () => {
return { ok: true };
});
app.listen({ port: 3000 });
Async errors and central handlers
Async functions can throw. Central handlers prevent leaks and keep responses uniform. Use Express error middleware with four parameters. Use Koa try or a top layer that wraps await next(). Use Fastify’s built in error handling with a custom setErrorHandler when you want structured output.
// express-errors.ts
import express, { Request, Response, NextFunction } from "express";
const app = express();
app.get("/fail", async (_req, _res) => {
throw new Error("Boom");
});
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
res.status(500).json({ error: (err as Error).message });
});
app.listen(3000);
Validation, logging, and error handling
Validation rejects bad inputs early. Logging records what happened and why. Error handling produces consistent responses. Combine these so that every request either succeeds clearly or fails clearly.
Schema validation with zod in Express
Define schemas once, infer types for handlers, and validate requests at the edge. This reduces boilerplate and prevents unsafe access to body fields.
// express-zod.ts
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
const CreateUser = z.object({
email: z.string().email(),
name: z.string().min(1)
});
type CreateUser = z.infer<typeof CreateUser>;
function validate<T>(schema: z.ZodSchema<T>) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.issues });
}
// attach typed data for downstream handlers
(req as any).data = parsed.data;
next();
};
}
app.post("/users", validate(CreateUser), (req, res) => {
const data = (req as any).data as CreateUser;
res.status(201).json({ id: "u_"+Date.now(), ...data });
});
app.listen(3000);
Built in validation and logging
Fastify integrates JSON Schema and high speed validation by default, and uses pino for structured logs. This combination helps when you want predictable formats and performance.
// fastify-schema.ts
import Fastify from "fastify";
const app = Fastify({ logger: true });
app.post("/users", {
schema: {
body: {
type: "object",
required: ["email","name"],
properties: {
email: { type: "string", format: "email" },
name: { type: "string", minLength: 1 }
}
}
}
}, async (req) => {
return { id: "u_"+Date.now(), ...(req.body as any) };
});
app.setErrorHandler((err, _req, res) => {
res.status(500).send({ error: err.message });
});
app.listen({ port: 3000 });
Request scoped correlation IDs
Attach a correlation ID so logs across services can be tied together. Use a header if present, or generate one for internal calls.
// correlation.ts (Express)
import { randomUUID } from "node:crypto";
export function correlation() {
return (req: any, res: any, next: any) => {
const id = req.get("x-correlation-id") || randomUUID();
req.correlationId = id;
res.set("x-correlation-id", id);
next();
};
}
Error taxonomy and mapping
Create a small set of error classes for common outcomes, then map them to HTTP status codes in one place. This prevents conditional scattering across handlers and makes responses predictable for clients.
// errors.ts
export class NotFound extends Error {}
export class Forbidden extends Error {}
export class BadRequest extends Error {}
export function toStatus(err: unknown): number {
if (err instanceof BadRequest) return 400;
if (err instanceof Forbidden) return 403;
if (err instanceof NotFound) return 404;
return 500;
}
Template rendering
Server rendered pages remain useful for dashboards, emails, and simple apps. Even in API first projects you often serve a health page and static files like a favicon, robots.txt, and documentation. Handle caching and content types carefully so clients receive fresh content when needed.
View engines in Express
Express supports template engines through app.set and a render call on the response. Choose a simple engine for small pages or a component based engine when you want reuse and layout control.
// express-views.ts
import express from "express";
import path from "node:path";
const app = express();
app.set("views", path.join(process.cwd(), "views"));
app.set("view engine", "pug");
app.get("/", (_req, res) => {
res.render("index", { title: "Home", message: "Hello" });
});
app.listen(3000);
Static files and caching
Serve static assets from a dedicated directory and apply cache headers that fit your deployment. Fingerprinted files can be cached for a long time, while HTML usually needs shorter lifetimes.
// static.ts (Express)
import express from "express";
import path from "node:path";
const app = express();
app.use("/assets", express.static(path.join(process.cwd(), "public"), {
etag: true,
lastModified: true,
maxAge: "1h",
setHeaders: (res, filepath) => {
if (filepath.endsWith(".html")) {
res.setHeader("Cache-Control", "no-cache");
}
}
}));
app.listen(3000);
Sending correct content types
Always send accurate content types so browsers and intermediaries do not guess. Most frameworks handle this automatically for common formats, but custom file types need an explicit header.
// content-type.ts (Koa)
import Koa from "koa";
const app = new Koa();
app.use(async ctx => {
ctx.set("Content-Type", "application/json; charset=utf-8");
ctx.body = JSON.stringify({ ok: true });
});
app.listen(3000);