What is Express?
Express is a minimal web framework for Node.js. It does not decide your folder structure, your database or your architecture. It gives you three things and gets out of the way: a way to match URLs, a request and response object, and a middleware chain that ties them together.
That restraint is the point. Express arrived in 2010, when Node’s built-in http module required you to write routing and body parsing by hand. It made those tasks a few lines. Fifteen years later it is still the default choice for Node APIs, and its ideas are copied by nearly every framework that followed.
If you understand Express, you understand the shape of server-side JavaScript.
The request and response cycle
At its core, an Express app is a function that receives a request and sends a response. Everything else is convenience on top.
import express from "express";
const app = express();
app.get("/", (req, res) => {
res.send("Hello, world");
});
The req object wraps Node’s incoming message: req.params, req.query, req.body and req.headers give you the input. The res object wraps the outgoing message: res.status(), res.json(), res.send() and res.set() shape the output. A handler ends the request by calling one of the res methods.
Routing
A route is a method, a path and one or more handlers. Paths can be static, parameterised with :name, or matched with patterns.
app.get("/posts", listPosts);
app.get("/posts/:id", getPost);
app.post("/posts", createPost);
app.put("/posts/:id", replacePost);
app.patch("/posts/:id", updatePost);
app.delete("/posts/:id", deletePost);
Route parameters arrive in req.params, and query strings in req.query.
app.get("/posts/:id", (req, res) => {
const { id } = req.params; // "/posts/42" -> "42"
const { fields } = req.query; // "?fields=title" -> "title"
res.json({ id, fields });
});
You can also pass several handlers to one route. They run in order until one ends the response, which is how you attach per-route middleware like validation or authorisation.
app.post("/posts", requireAuth, validatePost, createPost);
Middleware: the one idea to master
Middleware is the heart of Express. It is any function that receives (req, res, next) and either ends the response or calls next() to continue.
function logger(req, res, next) {
const start = Date.now();
res.on("finish", () => {
console.log(`${req.method} ${req.url} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
}
app.use(logger);
Middleware runs in the order it is registered. That single rule explains most Express behaviour: register the JSON parser before routes that read req.body, register authentication before protected routes, and register the 404 handler after every route.
There are three kinds worth knowing:
- Application middleware —
app.use(fn)runs for every request. - Router middleware —
router.use(fn)runs only for that router’s paths. - Route middleware —
app.get(path, fn, handler)runs only for that route.
A middleware that takes four arguments is an error handler and only runs when something calls next(err).
Routers keep large apps readable
As an app grows, a single file of routes becomes unmanageable. express.Router() lets you group related routes and mount them under a prefix.
// routes/posts.js
import { Router } from "express";
const router = Router();
router.get("/", listPosts);
router.post("/", createPost);
export default router;
// app.js
import posts from "./routes/posts.js";
app.use("/api/v1/posts", posts);
Now every file owns one resource, and the URL prefix lives in a single place. This is the structure that keeps Express projects from turning into a pile of endpoints.
Error handling
Errors should flow to one place. Call next(err) from anywhere and Express skips ahead to the first four-argument error handler.
app.get("/users/:id", async (req, res, next) => {
try {
const user = await db.user.findById(req.params.id);
if (!user) return res.status(404).json({ error: "not_found" });
res.json(user);
} catch (err) {
next(err);
}
});
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status ?? 500).json({
error: err.code ?? "internal_error",
message: err.message,
});
});
Express 5 forwards rejected promises to the error handler automatically, so async handlers no longer need a wrapper. Still, being explicit with try/catch makes intent obvious and keeps the code portable. Register a 404 handler after all routes so unmatched paths return a proper JSON error instead of Express’s default HTML page.
Reading the request body
Express does not parse bodies by default. Add the built-in parsers before the routes that need them.
app.use(express.json()); // application/json
app.use(express.urlencoded({ extended: true })); // form posts
For file uploads, reach for multer. For richer validation, combine parsing with a schema library like Zod or Joi and reject bad input early, before it reaches your handlers.
Security and production basics
Express is deliberately bare, so a few middleware one-liners cover the essentials:
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
app.use(helmet()); // sensible security headers
app.use(cors({ origin: "https://app.example.com" }));
app.use(rateLimit({ windowMs: 60_000, max: 100 }));
Also disable x-powered-by, keep dependencies updated, validate and cap request sizes, and never trust client input. These are the same concerns every backend has; Express simply gives you a small, well-known place to handle each one.
Testing
Because handlers are plain functions, Express is easy to test. supertest boots the app in-process and makes real HTTP assertions without opening a port.
import request from "supertest";
import app from "../app.js";
test("GET /posts returns a list", async () => {
const res = await request(app).get("/posts").expect(200);
expect(Array.isArray(res.body)).toBe(true);
});
Separate the app definition (app.js) from the server start (server.js) so tests can import the app without listening. That one split makes integration testing trivial.
Best practices
- Keep route handlers thin; move logic into services and data access layers.
- Split routes by feature with
express.Router()and mount them under a version prefix. - Register middleware deliberately and in order: parsers, then auth, then routes, then 404, then errors.
- Forward every error with
next(err)and format responses in one place. - Validate and cap all input before it reaches business logic.
- Use
helmet,corsand rate limiting from day one. - Separate the app from the server so tests stay fast.
Common mistakes
- Forgetting
express.json()and wondering whyreq.bodyis undefined. - Dropping rejected promises in async handlers on Express 4.
- Registering the 404 or error handler before the routes it should catch.
- Growing a single routes file into hundreds of lines.
- Returning 200 for errors instead of using status codes.
- Trusting
req.bodywithout validation. - Mixing
res.send,res.jsonandres.endunpredictably.
Where to go next
Express teaches the request, the response and the middleware chain — concepts you will reuse forever. If you want more speed and built-in validation, read the Fastify guide next. If you want structure and dependency injection for a large team, move to NestJS. For edge and serverless deployments, Hono offers the same style on a web-standards runtime. And if any of this felt fast, revisit the Node.js basics underneath it.