Express.js with TypeScript scales to production with the right patterns — RequestHandler<Params, ResBody, ReqBody, ReqQuery> types handler generics. asyncHandler(fn) wraps async functions to catch rejections. Zod validation middleware: validateBody(schema) returns a typed RequestHandler that calls schema.safeParse(req.body). express-rate-limit with RedisStore prevents abuse. helmet() sets security headers. multer({ storage: multerS3(...) }) handles S3 uploads. JWT: verify(token, secret) in middleware with req.user injection via declaration merging. pino with pino-http logs structured JSON. Dependency injection with tsyringe: @injectable() classes resolved by container.resolve(ServiceClass). Pagination: parsePagination(req.query) returns { page, pageSize, offset }. morgan or pino-http for request logging. Circuit breaker with opossum for external service calls. Claude Code generates Express middleware stacks, typed handlers, rate limiting, and upload pipelines.
CLAUDE.md for Express.js Advanced
## Express.js Advanced Stack
- Version: express >= 4.21, express >= 5 beta, typescript >= 5
- Typed handler: const handler: RequestHandler<{ id: string }, UserResponse, UpdateBody> = async (req, res) => { ... }
- Async: asyncHandler(async (req, res) => { const data = await service.get(req.params.id); res.json(data) })
- Validation: app.post("/users", validateBody(CreateUserSchema), handler)
- Error handler: app.use((err: Error, req, res, next): void => { res.status(500).json({ message: err.message }) })
- Rate limit: app.use("/api/", rateLimit({ windowMs: 15*60*1000, max: 100, store: new RedisStore({ client }) }))
- JWT: app.use("/api/protected", authenticate) — injects req.user
Core Utilities
// src/lib/express/types.ts — typed Express extensions
import type { Request, Response, NextFunction, RequestHandler } from "express"
import type { ZodSchema, z } from "zod"
// Augment Express Request to include validated data
declare global {
namespace Express {
interface Request {
user?: {
id: string
email: string
role: "user" | "admin"
}
pagination: {
page: number
pageSize: number
offset: number
}
}
}
}
// Async handler wrapper — catches promise rejections and passes to next()
export function asyncHandler<
P = {},
ResBody = any,
ReqBody = any,
ReqQuery = qs.ParsedQs,
>(
fn: (
req: Request<P, ResBody, ReqBody, ReqQuery>,
res: Response<ResBody>,
next: NextFunction,
) => Promise<void | Response>,
): RequestHandler<P, ResBody, ReqBody, ReqQuery> {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}
// Zod body validation middleware factory
export function validateBody<T extends ZodSchema>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body)
if (!result.success) {
res.status(400).json({
message: "Validation error",
errors: result.error.flatten().fieldErrors,
})
return
}
req.body = result.data
next()
}
}
// Zod query validation middleware factory
export function validateQuery<T extends ZodSchema>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.query)
if (!result.success) {
res.status(400).json({
message: "Query validation error",
errors: result.error.flatten().fieldErrors,
})
return
}
req.query = result.data as any
next()
}
}
// Pagination middleware
export function paginationMiddleware(
defaultPageSize = 10,
maxPageSize = 100,
): RequestHandler {
return (req, res, next) => {
const page = Math.max(1, parseInt(String(req.query.page ?? "1"), 10) || 1)
const requestedSize = parseInt(String(req.query.pageSize ?? String(defaultPageSize)), 10)
const pageSize = Math.min(maxPageSize, Math.max(1, requestedSize || defaultPageSize))
req.pagination = {
page,
pageSize,
offset: (page - 1) * pageSize,
}
next()
}
}
JWT Authentication
// src/middleware/auth.ts — JWT authentication middleware
import type { RequestHandler } from "express"
import { verify } from "jsonwebtoken"
import { db } from "../db"
import { users } from "../db/schema"
import { eq } from "drizzle-orm"
const JWT_SECRET = process.env.JWT_SECRET!
export const authenticate: RequestHandler = async (req, res, next) => {
const token =
req.headers.authorization?.replace("Bearer ", "") ??
(req.cookies?.access_token as string | undefined)
if (!token) {
res.status(401).json({ message: "Authentication required" })
return
}
try {
const payload = verify(token, JWT_SECRET) as {
sub: string
email: string
role: "user" | "admin"
}
// Optionally hydrate user from DB (adds latency, ensures up-to-date)
// For performance, trust the JWT claims and skip DB lookup in hot paths
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
}
next()
} catch {
res.status(401).json({ message: "Invalid or expired token" })
}
}
export const requireAdmin: RequestHandler = (req, res, next) => {
if (!req.user) {
res.status(401).json({ message: "Authentication required" })
return
}
if (req.user.role !== "admin") {
res.status(403).json({ message: "Admin access required" })
return
}
next()
}
export function requireRole(...roles: string[]): RequestHandler {
return (req, res, next) => {
if (!req.user) {
res.status(401).json({ message: "Authentication required" })
return
}
if (!roles.includes(req.user.role)) {
res.status(403).json({ message: `Requires one of: ${roles.join(", ")}` })
return
}
next()
}
}
Application Setup
// src/app.ts — Express app configuration
import express from "express"
import helmet from "helmet"
import compression from "compression"
import cors from "cors"
import rateLimit from "express-rate-limit"
import RedisStore from "rate-limit-redis"
import { createClient } from "redis"
import pino from "pino"
import pinoHttp from "pino-http"
import { postsRouter } from "./routes/posts"
import { usersRouter } from "./routes/users"
import { authRouter } from "./routes/auth"
import type { Request, Response, NextFunction } from "express"
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
transport: process.env.NODE_ENV === "development"
? { target: "pino-pretty" }
: undefined,
})
const redisClient = createClient({ url: process.env.REDIS_URL })
redisClient.connect().catch(logger.error.bind(logger))
export function createApp() {
const app = express()
// ── Security ───────────────────────────────────────────────────────────
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}))
// ── Performance ────────────────────────────────────────────────────────
app.use(compression())
// ── CORS ───────────────────────────────────────────────────────────────
app.use(cors({
origin: (origin, callback) => {
const allowed = [
process.env.FRONTEND_URL ?? "http://localhost:3000",
"http://localhost:3001",
]
if (!origin || allowed.includes(origin)) callback(null, true)
else callback(new Error("Not allowed by CORS"))
},
credentials: true,
}))
// ── Body parsing ───────────────────────────────────────────────────────
app.use(express.json({ limit: "1mb" }))
app.use(express.urlencoded({ extended: true }))
// ── Logging ────────────────────────────────────────────────────────────
app.use(pinoHttp({ logger }))
// ── Rate limiting ──────────────────────────────────────────────────────
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
keyGenerator: (req) => req.user?.id ?? req.ip ?? "anonymous",
})
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // 10 login attempts per hour
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
prefix: "rl:auth:",
}),
})
// ── Routes ─────────────────────────────────────────────────────────────
app.get("/health", (_req, res) => {
res.json({ ok: true, timestamp: new Date().toISOString() })
})
app.use("/api/auth", authLimiter, authRouter)
app.use("/api/posts", apiLimiter, postsRouter)
app.use("/api/users", apiLimiter, usersRouter)
// ── 404 handler ────────────────────────────────────────────────────────
app.use((_req, res) => {
res.status(404).json({ message: "Route not found" })
})
// ── Error handler ──────────────────────────────────────────────────────
app.use((err: Error, req: Request, res: Response, _next: NextFunction): void => {
const log = (req as any).log ?? logger
log.error({ err }, "Unhandled error")
if (res.headersSent) return
res.status(500).json({
message: process.env.NODE_ENV === "production"
? "Internal server error"
: err.message,
})
})
return app
}
Typed Router Example
// src/routes/posts.ts — typed Express router
import { Router } from "express"
import { z } from "zod"
import { asyncHandler, validateBody, validateQuery, paginationMiddleware } from "../lib/express/types"
import { authenticate } from "../middleware/auth"
import { db } from "../db"
import { posts } from "../db/schema"
import { eq, ilike, desc } from "drizzle-orm"
export const postsRouter = Router()
const CreatePostSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
excerpt: z.string().max(500).optional(),
published: z.boolean().default(false),
})
const PostQuerySchema = z.object({
search: z.string().optional(),
published: z.coerce.boolean().optional(),
})
postsRouter.get(
"/",
paginationMiddleware(10, 50),
validateQuery(PostQuerySchema),
asyncHandler(async (req, res) => {
const { page, pageSize, offset } = req.pagination
const { search, published } = req.query as z.infer<typeof PostQuerySchema>
const where = []
if (search) where.push(ilike(posts.title, `%${search}%`))
if (published !== undefined) where.push(eq(posts.published, published))
const result = await db.query.posts.findMany({
where: where.length > 0 ? and(...where) : undefined,
limit: pageSize,
offset,
orderBy: desc(posts.createdAt),
})
res.json({ posts: result, page, pageSize })
}),
)
postsRouter.post(
"/",
authenticate,
validateBody(CreatePostSchema),
asyncHandler(async (req, res) => {
const body = req.body as z.infer<typeof CreatePostSchema>
const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
const [post] = await db.insert(posts).values({
...body,
slug,
authorId: req.user!.id,
}).returning()
res.status(201).json(post)
}),
)
For the Fastify alternative when a Node.js framework with better raw performance, JSON Schema validation built-in, a plugin system with lifecycle hooks, and TypeBox integration for type-safe JSON schemas is preferred — Fastify consistently benchmarks 2-3x faster than Express while Express has a much larger middleware ecosystem and longer community track record, see the Fastify guide. For the Hono alternative when deploying to edge runtimes (Cloudflare Workers, Deno Deploy) or Bun for maximum performance — Hono’s ergonomics are similar to Express but it’s built for edge from the ground up, see the Hono guide. The Claude Skills 360 bundle includes Express.js advanced skill sets covering middleware, auth, and rate limiting. Start with the free tier to try production Express API generation.