Express.js
"Express.js with TypeScript: routing, middleware patterns, error handling, validation, authentication, static files, CORS, and production-ready configuration"
Express.js is the foundational Node.js web framework — minimal, unopinionated, and composable. It provides a thin layer over Node's HTTP module with a middleware pipeline, routing, and request/response utilities. Everything else — validation, authentication, database access — is added through middleware and libraries you choose.
## Key Points
- Always use `asyncHandler` or express-async-errors to catch promise rejections; unhandled rejections crash the process.
- Place the error-handling middleware last, after all route definitions, and ensure it has exactly four parameters.
- Return early after sending a response (`return` after `res.json()`) to avoid "headers already sent" errors.
- Use `helmet()` for security headers, `cors()` with explicit origins, and `express.json({ limit })` to prevent payload abuse.
- Separate `app.ts` (configuration) from `server.ts` (listening) so the app is testable without starting a server.
- Set up graceful shutdown handlers for SIGTERM and SIGINT in production deployments.
- Use router-level middleware for route-specific concerns (auth on `/api/*`) rather than applying globally.
- **Forgetting to call `next()`.** Middleware that neither sends a response nor calls `next()` hangs the request indefinitely.
- **Using `app.use(cors())` with no options.** This allows all origins; always specify allowed origins in production.
- **Putting error handlers before routes.** Express error middleware must be registered after all routes and other middleware.
- **Throwing errors in async handlers without a wrapper.** Express 4 does not catch rejected promises; they become unhandled rejections.
- **Mutating `res` after it is sent.** Check `res.headersSent` if there is any ambiguity about whether a response was already sent.skilldb get api-frameworks-skills/Express.jsFull skill: 355 linesExpress.js
Core Philosophy
Express.js is the foundational Node.js web framework — minimal, unopinionated, and composable. It provides a thin layer over Node's HTTP module with a middleware pipeline, routing, and request/response utilities. Everything else — validation, authentication, database access — is added through middleware and libraries you choose.
With TypeScript, Express gains type safety for request handlers, middleware, and route parameters. The pattern of layering middleware functions creates a clean separation of concerns: each middleware handles one job (logging, auth, validation, error handling) and passes control via next().
Setup
TypeScript Project Configuration
// Install:
// npm install express cors helmet morgan
// npm install -D typescript @types/express @types/cors @types/morgan ts-node-dev
// tsconfig.json essentials:
// { "compilerOptions": { "target": "ES2022", "module": "commonjs",
// "outDir": "./dist", "rootDir": "./src", "strict": true,
// "esModuleInterop": true, "resolveJsonModule": true } }
// src/app.ts
import express, { type Request, type Response, type NextFunction } from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
const app = express();
// Core middleware
app.use(helmet());
app.use(morgan("combined"));
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(",") ?? "http://localhost:3000" }));
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true }));
// Mount routes
import { userRouter } from "./routes/users";
import { postRouter } from "./routes/posts";
app.use("/api/users", userRouter);
app.use("/api/posts", postRouter);
// Health check
app.get("/health", (_req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});
export { app };
Server Entry Point
// src/server.ts
import { app } from "./app";
const PORT = parseInt(process.env.PORT ?? "3000", 10);
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
// Graceful shutdown
const shutdown = (signal: string) => {
console.log(`${signal} received, shutting down gracefully`);
server.close(() => {
console.log("HTTP server closed");
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
Key Techniques
Typed Route Handlers
// src/types/express.d.ts — Extend Request for auth
declare global {
namespace Express {
interface Request {
user?: { id: string; email: string; role: "admin" | "user" };
}
}
}
// src/routes/users.ts
import { Router, type Request, type Response } from "express";
interface CreateUserBody {
name: string;
email: string;
password: string;
}
interface UserParams {
id: string;
}
const router = Router();
router.get("/", async (_req: Request, res: Response) => {
const users = await db.user.findMany({
select: { id: true, name: true, email: true, createdAt: true },
});
res.json({ data: users });
});
router.get("/:id", async (req: Request<UserParams>, res: Response) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
res.json({ data: user });
});
router.post("/", async (req: Request<{}, {}, CreateUserBody>, res: Response) => {
const { name, email, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 12);
const user = await db.user.create({
data: { name, email, password: hashedPassword },
});
res.status(201).json({ data: { id: user.id, name: user.name, email: user.email } });
});
export { router as userRouter };
Middleware Patterns
// src/middleware/auth.ts — JWT authentication
import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";
export const authenticate = (req: Request, res: Response, next: NextFunction): void => {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing authorization token" });
return;
}
try {
const token = header.slice(7);
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
sub: string;
email: string;
role: "admin" | "user";
};
req.user = { id: payload.sub, email: payload.email, role: payload.role };
next();
} catch {
res.status(401).json({ error: "Invalid or expired token" });
}
};
// Role-based authorization
export const authorize = (...roles: Array<"admin" | "user">) => {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user || !roles.includes(req.user.role)) {
res.status(403).json({ error: "Insufficient permissions" });
return;
}
next();
};
};
// Usage
router.delete("/:id", authenticate, authorize("admin"), async (req, res) => {
await db.user.delete({ where: { id: req.params.id } });
res.status(204).send();
});
Request Validation
// src/middleware/validate.ts
import { z, type ZodSchema } from "zod";
import type { Request, Response, NextFunction } from "express";
interface ValidationSchemas {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
export const validate = (schemas: ValidationSchemas) => {
return (req: Request, res: Response, next: NextFunction): void => {
const errors: Record<string, unknown> = {};
if (schemas.body) {
const result = schemas.body.safeParse(req.body);
if (!result.success) errors.body = result.error.flatten().fieldErrors;
else req.body = result.data;
}
if (schemas.query) {
const result = schemas.query.safeParse(req.query);
if (!result.success) errors.query = result.error.flatten().fieldErrors;
else req.query = result.data;
}
if (schemas.params) {
const result = schemas.params.safeParse(req.params);
if (!result.success) errors.params = result.error.flatten().fieldErrors;
else req.params = result.data;
}
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: "Validation failed", details: errors });
return;
}
next();
};
};
// Usage in routes
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
});
router.post("/", validate({ body: createPostSchema }), async (req, res) => {
// req.body is validated and typed
const post = await db.post.create({ data: req.body });
res.status(201).json({ data: post });
});
Error Handling
// src/middleware/errorHandler.ts
import type { Request, Response, NextFunction } from "express";
class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
// Async handler wrapper — catches promise rejections
const asyncHandler = (fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) => {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
};
// Central error handler — must have 4 parameters
const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction): void => {
if (err instanceof AppError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
console.error("Unexpected error:", err);
res.status(500).json({ error: "Internal server error" });
};
// Mount AFTER all routes
app.use(errorHandler);
// Usage in routes
router.get(
"/:id",
asyncHandler(async (req, res) => {
const item = await db.item.findUnique({ where: { id: req.params.id } });
if (!item) throw new AppError(404, "Item not found");
res.json({ data: item });
})
);
export { AppError, asyncHandler, errorHandler };
Static Files and File Uploads
import express from "express";
import multer from "multer";
import path from "path";
// Serve static files
app.use("/public", express.static(path.join(__dirname, "../public"), {
maxAge: "1d",
etag: true,
}));
// File upload with multer
const upload = multer({
storage: multer.diskStorage({
destination: "./uploads",
filename: (_req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${unique}${path.extname(file.originalname)}`);
},
}),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
cb(null, allowed.includes(file.mimetype));
},
});
router.post("/avatar", authenticate, upload.single("avatar"), async (req, res) => {
if (!req.file) {
res.status(400).json({ error: "No valid file uploaded" });
return;
}
await db.user.update({
where: { id: req.user!.id },
data: { avatarPath: req.file.path },
});
res.json({ path: req.file.path });
});
Best Practices
- Always use
asyncHandleror express-async-errors to catch promise rejections; unhandled rejections crash the process. - Place the error-handling middleware last, after all route definitions, and ensure it has exactly four parameters.
- Return early after sending a response (
returnafterres.json()) to avoid "headers already sent" errors. - Use
helmet()for security headers,cors()with explicit origins, andexpress.json({ limit })to prevent payload abuse. - Separate
app.ts(configuration) fromserver.ts(listening) so the app is testable without starting a server. - Set up graceful shutdown handlers for SIGTERM and SIGINT in production deployments.
- Use router-level middleware for route-specific concerns (auth on
/api/*) rather than applying globally.
Anti-Patterns
- Forgetting to call
next(). Middleware that neither sends a response nor callsnext()hangs the request indefinitely. - Using
app.use(cors())with no options. This allows all origins; always specify allowed origins in production. - Putting error handlers before routes. Express error middleware must be registered after all routes and other middleware.
- Throwing errors in async handlers without a wrapper. Express 4 does not catch rejected promises; they become unhandled rejections.
- Mutating
resafter it is sent. Checkres.headersSentif there is any ambiguity about whether a response was already sent. - Storing sensitive data in JWT payloads. JWTs are base64-encoded, not encrypted; include only IDs and roles, never passwords or PII.
Install this skill directly: skilldb add api-frameworks-skills
Related Skills
Elysia
"Elysia: Bun-native web framework with type-safe routing, TypeBox validation, plugins, Eden treaty client, lifecycle hooks, and Swagger documentation"
Fastify
Fastify: high-performance Node.js web framework with schema-based validation, logging, plugin architecture, and TypeScript support
Apollo GraphQL
"Apollo GraphQL: schema design, resolvers, Apollo Server, Apollo Client with React, useQuery/useMutation, caching strategies, subscriptions, and codegen"
Hono
"Hono: ultra-fast web framework for edge, serverless, and Node.js — middleware, routing, Zod validation, JWT, CORS, RPC client, and JSX support"
Koa
Koa: lightweight Node.js framework by the Express team with async/await middleware, context object, and composable architecture
NestJS
NestJS: progressive Node.js framework with decorators, dependency injection, modules, guards, pipes, and interceptors for scalable APIs