Express.js has been the default Node.js backend framework for over a decade, and it's still the right first choice for most APIs precisely because it does so little by default.
Express.js is a minimal, unopinionated web framework for Node.js that handles routing, middleware composition, and request/response helpers, leaving everything else — database access, validation, auth — up to you or the libraries you choose to add. That minimalism is both its biggest strength and the source of most production Express bugs, since almost nothing is enforced for you.
Why Express Matters (and When to Skip It)
Express's middleware model — a chain of functions each handling part of the request lifecycle — is genuinely simple to reason about, and its ecosystem of middleware (helmet, cors, morgan, express-rate-limit) covers most cross-cutting concerns without custom code. For a straightforward REST API, it's still hard to beat for getting something running fast.
Skip Express for projects that need built-in structure — dependency injection, decorators, strict module boundaries — where NestJS enforces conventions Express leaves entirely up to you. Also skip it for edge/serverless-first deployments where Hono's smaller footprint and Web Standards API compatibility fit better.
Getting Started with Express
A minimal but production-shaped starting point:
import express, { Request, Response, NextFunction } from "express";
import helmet from "helmet";
import cors from "cors";
const app = express();
app.use(helmet());
app.use(cors());
app.use(express.json());
app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok" });
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
app.listen(3000, () => console.log("Listening on :3000"));
The error-handling middleware (four arguments — Express identifies it by arity) must be registered last, after all routes.
Core Express Concepts Every Developer Should Know
Middleware runs in registration order, and next() controls the chain. Forgetting to call next() in a middleware that doesn't send a response hangs the request forever — this is the single most common Express bug for newcomers.
function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: "Unauthorized" }); // must return, or code below still runs
}
next();
}
Router instances scope middleware to a path prefix, keeping route files modular:
import { Router } from "express";
const usersRouter = Router();
usersRouter.get("/:id", getUser);
usersRouter.post("/", createUser);
app.use("/api/users", usersRouter);
Async route handlers need explicit error forwarding. Express doesn't automatically catch rejected promises in older versions — an unhandled rejection in an async handler crashes silently unless wrapped:
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) {
return (req: Request, res: Response, next: NextFunction) => fn(req, res, next).catch(next);
}
app.get("/users/:id", asyncHandler(async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
}));
(Express 5, released more recently, handles this automatically — check your version before assuming you need the wrapper.)
Common Express Mistakes and How to Fix Them
Mistake 1: no centralized error handler. Scattering try/catch with duplicated error-response logic across every route is repetitive and inconsistent. Fix: throw typed errors from route handlers and let a single error-handling middleware format the response.
Mistake 2: trusting req.body without validation. Express parses JSON but doesn't validate shape — passing unvalidated input straight to a database query is how injection and type-confusion bugs happen. Fix: validate with Zod or a similar schema library at the top of every handler that accepts input.
Mistake 3: forgetting express.json() size limits. The default body size limit is low but not documented clearly, and large payload needs (file metadata, bulk imports) silently 413 without an obvious error. Fix: explicitly set express.json({ limit: "10mb" }) when you know payloads will be larger.
When Should You Use Express Instead of Fastify or Hono?
Use Express when ecosystem breadth and team familiarity matter more than raw throughput — it has the largest middleware ecosystem of any Node.js framework by far. Reach for Fastify when request throughput is a measured bottleneck, or Hono when deploying to edge runtimes where Express's Node-specific APIs don't apply.
Express in Production
Add helmet for security headers and express-rate-limit for basic abuse protection from day one — both are a few lines and prevent entire categories of incidents. For anything beyond a small service, structure routes into feature-based routers early; a single index.ts with fifty inline route handlers becomes unmaintainable fast.
Wrap every async route handler in error-forwarding logic (or upgrade to Express 5) before you ship — an unhandled promise rejection in a request handler is one of the most common causes of a Node process silently dying in production.