Skip to main content
Technology & EngineeringApi Frameworks355 lines

Express.js

"Express.js with TypeScript: routing, middleware patterns, error handling, validation, authentication, static files, CORS, and production-ready configuration"

Quick Summary18 lines
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 lines
Paste into your CLAUDE.md or agent config

Express.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 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.

Anti-Patterns

  • 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.
  • 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

Get CLI access →