Express.js
REST APIs and server logic with Express v4.x middleware and routing.
When to Use
- REST APIs
- Middleware stacks
- HTTP request/response handling
Don’t use for:
- Static site generation (use Next.js/Astro)
- GraphQL-first APIs (use Apollo Server)
- Edge/serverless zero cold-start (use Hono)
Critical Patterns
✅ REQUIRED: Router Modularization
Group routes into Router instances for clean app.ts.
// CORRECT: modular router in routes/users.ts
const router = Router();
router.get("/", listUsers);
router.post("/", createUser);
export default router;
// WRONG: all routes dumped directly in app.ts
✅ REQUIRED: Async Error Wrapper
Express 4 doesn’t catch rejected promises. Wrap async handlers.
// CORRECT: wrapper forwards rejections to error middleware
const asyncHandler = (fn: RequestHandler): RequestHandler =>
(req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
router.get("/users/:id", asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError("User not found");
res.json(user);
}));
✅ REQUIRED: Centralized Error Middleware
Single error handler as last middleware in stack.
// CORRECT: 4-argument signature signals error middleware
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
const status = err.statusCode ?? 500;
res.status(status).json({ error: err.message });
};
app.use(errorHandler);
// WRONG: try/catch in every route returning res.status(500)
✅ REQUIRED: Input Validation Middleware
Validate bodies before route logic.
const CreateUser = z.object({ name: z.string(), email: z.string().email() });
const validate = (schema: z.ZodSchema): RequestHandler =>
(req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error.flatten());
req.body = result.data;
next();
};
✅ REQUIRED: Proper Status Codes
Return correct HTTP codes per operation.
// CORRECT: 201 for creation, 204 for no-content delete
router.post("/users", asyncHandler(async (req, res) => {
const user = await db.createUser(req.body);
res.status(201).json(user);
}));
router.delete("/users/:id", asyncHandler(async (req, res) => {
await db.deleteUser(req.params.id);
res.status(204).end();
}));
Decision Tree
Multiple resource routes?
→ Group into a Router per resource
Async handler?
→ Wrap with asyncHandler utility
Need auth?
→ Add auth middleware before route handlers
Input from client?
→ Validate with schema middleware
Creating a resource?
→ Return 201, not 200
Deleting a resource?
→ Return 204 with no body
Unhandled error?
→ Let centralized error middleware respond
Example
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
const asyncHandler = (fn: Function) =>
(req: any, res: any, next: any) => Promise.resolve(fn(req, res, next)).catch(next);
const UserSchema = z.object({ name: z.string(), email: z.string().email() });
app.get("/users/:id", asyncHandler(async (req: any, res: any) => {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
res.json(user);
}));
app.post("/users", asyncHandler(async (req: any, res: any) => {
const data = UserSchema.parse(req.body);
res.status(201).json(await db.createUser(data));
}));
Edge Cases
- Async errors: Express 4 swallows rejected promises; use async wrapper.
- Middleware order: Error middleware after routes; auth before protected routes.
- Large payloads: Set
express.json({ limit: "1mb" })to prevent 413/memory issues. - Double response:
res.json()twice throws; guard withif (res.headersSent). - Missing Content-Type: No
Content-Type: application/json→ emptyreq.body.
Checklist
- Every async route handler is wrapped or uses express-async-errors
- A centralized error middleware is the last
app.usecall - Routes are grouped into Router modules by resource
- Request bodies are validated before business logic runs
- Correct HTTP status codes are used (201, 204, 400, 404, 500)
-
express.json()has an explicit size limit - Sensitive headers (CORS, Helmet) are configured