Express.js

All 21 notes on one page

Fundamentals

1

What is Express

beginner express fundamentals http

Express is a minimal, unopinionated web framework for Node.js. In simple language, it’s a thin layer that sits on top of Node’s built-in http module and makes building HTTP servers way less painful.

If you’ve ever tried writing a server with raw http.createServer(), you know the drill — parsing URLs, reading request bodies, matching routes by hand, sending headers manually. Express handles all that boring plumbing for us.

Why we use it

The Node http module is powerful but low-level. Even sending a JSON response means setting headers, calling JSON.stringify, calling res.end. Express gives us:

  • Routingapp.get('/users', handler) instead of parsing req.url ourselves
  • Middleware pipeline — a clean way to chain logic (auth, logging, body parsing)
  • Request/response helpersres.json(), req.params, req.query
  • Huge ecosystem — thousands of middlewares on npm (cors, helmet, morgan, etc.)

Think of it like jQuery for HTTP servers — it’s not magic, it just wraps the ugly parts.

The Stack
Your App (routes, business logic)
Express (routing, middleware, helpers)
Node http module (sockets, parsing)
OS networking (TCP)

”Unopinionated” — what that means

Express doesn’t force a folder structure, ORM, validation library, or anything else. You bring your own pieces. That’s a double-edged sword — great flexibility, but two Express codebases at two companies can look completely different.

Compare that to NestJS or Rails, which dictate where things go. Express says: “Here’s routing and middleware. Figure out the rest.”

Hello World

npm init -y
npm install express
const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Hello World');
});

app.get('/users/:id', (req, res) => {
  res.json({ id: req.params.id, name: 'Manish' });
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

That’s a full HTTP server. Four lines of actual logic. The same thing in raw http would be 30+ lines.

What Express is NOT

  • Not a full framework — no ORM, no templating opinion, no auth built-in
  • Not async-first in design — it predates async/await, so error handling needs some care (we’ll cover this in middleware notes)
  • Not the fastest — Fastify benchmarks ~2x faster, but Express is fast enough for 99% of apps

When to pick Express

  • Small to medium REST APIs
  • You want maximum flexibility
  • The team already knows it (huge community, easy hiring)
  • You need a specific middleware that only exists for Express

For high-throughput services or strict structure, we’d look at Fastify or NestJS — covered in the next note.


2

Express vs Koa vs Fastify vs NestJS

intermediate express koa fastify nestjs comparison

This is one of the most common backend interview questions: “Why Express over Fastify?” or “When would you use NestJS?” Let’s break down what actually differs.

The 30-second version

  • Express — the default. Old, stable, huge ecosystem, unopinionated.
  • Koa — Express’s spiritual successor. Async/await first, no built-in router or body parser.
  • Fastify — Express-like API but ~2x faster. Schema-based validation built-in.
  • NestJS — Opinionated framework on top of Express (or Fastify). TypeScript, decorators, DI — basically Angular for the backend.
Feature Express Koa Fastify NestJS
Perf (req/s)~15k~17k~30k~15k
OpinionatedNoNoSlightlyVery
Async/await nativeNo*YesYesYes
TypeScript firstNoNoYesYes
Ecosystem sizeHugeSmallMediumLarge

*Express 5 has better async error propagation, but Express 4 (still dominant) needs try/catch or wrappers.

Express

The default choice. Used everywhere — old projects, tutorials, Stack Overflow answers. The ecosystem (middlewares, integrations, Stack Overflow answers) is unmatched.

const express = require('express');
const app = express();

app.get('/users/:id', (req, res) => {
  res.json({ id: req.params.id });
});

Pick when: building a typical REST API, team already knows it, need a specific middleware.

Koa

Built by the same team that made Express. Smaller, async-first, uses a ctx (context) object instead of req, res. No bundled router or body parser — you install separately.

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx) => {
  ctx.body = { hello: 'world' };
});

Pick when: you want a clean async-first base and don’t mind assembling pieces yourself. Honestly, Koa is fading — Fastify ate its lunch.

Fastify

Express-like API but with serious engineering behind perf. Uses JSON Schema for validation AND fast serialization (it compiles a fast JSON.stringify from your schema). Built-in logger (Pino), plugins for everything.

const fastify = require('fastify')({ logger: true });

fastify.get('/users/:id', async (req) => {
  return { id: req.params.id };
});

fastify.listen({ port: 3000 });

Pick when: you need high throughput, want schema validation built-in, starting a new project without legacy Express middleware to keep.

NestJS

A full framework — controllers, services, modules, dependency injection. Heavy use of TypeScript decorators. Feels like Spring Boot or Angular.

@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return { id };
  }
}

Pick when: large team, large codebase, want enforced structure, building something complex with many modules. Overkill for a 5-route service.

The interview answer

“Express is the safe default — huge ecosystem, easy hiring. We’d pick Fastify for high-throughput APIs where the 2x perf actually matters, since it has Express-like DX with schema validation built in. NestJS makes sense for large teams that benefit from enforced structure and DI — but it’s overhead for small services. Koa is mostly legacy at this point.”

That’s the call. The framework rarely matters as much as the team’s familiarity with it.


Routing

3

Routing Basics

beginner express routing http

A route in Express is a mapping from (HTTP method, URL path) to a handler function. When a request comes in, Express finds the first matching route and runs its handler.

In simple language — “when someone does a GET on /users, run this function.” That’s it.

The shape

app.METHOD(PATH, HANDLER);
  • METHOD — http verb in lowercase: get, post, put, delete, patch
  • PATH — URL pattern: /users, /users/:id, /api/*
  • HANDLER(req, res) => { ... }
const express = require('express');
const app = express();

app.get('/users', (req, res) => {
  res.json([{ id: 1, name: 'Manish' }]);
});

app.post('/users', (req, res) => {
  res.status(201).json({ id: 2, name: 'New User' });
});

app.put('/users/:id', (req, res) => {
  res.json({ id: req.params.id, updated: true });
});

app.delete('/users/:id', (req, res) => {
  res.status(204).end();
});

app.listen(3000);

Sending responses

The res object has helpers for every common case. You only need to call one of these per request — calling two throws “Cannot set headers after they are sent.”

res.send('Hello');              // auto-detects content type
res.json({ ok: true });         // sets Content-Type: application/json
res.status(404).json({ ... });  // chainable status code
res.sendStatus(204);            // sets status + sends status text
res.redirect('/login');         // 302 redirect
res.redirect(301, '/new');      // permanent redirect
res.end();                      // no body, just close

Status code defaults to 200. We set it explicitly for created (201), no content (204), errors (4xx/5xx).

Request Lifecycle
Client
GET /users/42
Express matches
route
Handler runs
res.json(...)
Response sent

Order matters

Express checks routes top to bottom. The first one that matches wins. So a generic catch-all should always go last.

app.get('/users/me', (req, res) => res.json({ self: true }));
app.get('/users/:id', (req, res) => res.json({ id: req.params.id }));

// If we flipped these, /users/me would match :id first and return { id: 'me' }

Multiple handlers per route

We can pass multiple functions — they run in order, each calling next() to pass control:

const auth = (req, res, next) => {
  if (!req.headers.authorization) return res.sendStatus(401);
  next();
};

app.get('/profile', auth, (req, res) => {
  res.json({ name: 'Manish' });
});

This is just middleware — covered in detail later. Useful for per-route auth checks.

app.all() and route chaining

For all methods on a path, use app.all. To group methods on one path cleanly, use app.route:

app.route('/books')
  .get((req, res) => res.json([]))
  .post((req, res) => res.status(201).json({}))
  .put((req, res) => res.json({}));

This is mostly stylistic — same as writing three separate app.get/post/put calls.

What about async handlers?

Express 4 doesn’t auto-catch rejected promises. If we throw inside async (req, res), the request hangs forever:

// BROKEN in Express 4
app.get('/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id); // if this rejects, request hangs
  res.json(user);
});

Fix it with try/catch and next(err), or use a wrapper like express-async-errors. Express 5 handles this natively — but most production apps are still on v4.


4

Route Parameters & Query Strings

beginner express routing params query

Two ways to get dynamic data out of a URL:

  • Route params — part of the path itself: /users/4242 is the user ID
  • Query strings — after the ?: /users?role=admin&limit=10

The distinction matters: params identify a resource, query strings filter or modify a request.

Route parameters — req.params

We mark a dynamic segment with :name. Express captures it into req.params:

app.get('/users/:id', (req, res) => {
  res.json({ userId: req.params.id });
});

// GET /users/42 → { "userId": "42" }

Multiple params work the same way:

app.get('/users/:userId/posts/:postId', (req, res) => {
  const { userId, postId } = req.params;
  res.json({ userId, postId });
});

// GET /users/42/posts/9 → { "userId": "42", "postId": "9" }
Route matching
Pattern: /users/:userId/posts/:postId
URL: /users/42/posts/9
req.params
{ userId: "42", postId: "9" }
⚠ values are always strings

Gotcha #1: Params are always strings. req.params.id for /users/42 is "42", not 42. Parse with Number(req.params.id) or parseInt.

Gotcha #2: They’re URL-decoded automatically. /users/john%20doe gives req.params.id === "john doe".

Optional params

In Express 4, append ?:

// Express 4
app.get('/users/:id/posts/:postId?', (req, res) => {
  if (req.params.postId) {
    // single post
  } else {
    // all posts for user
  }
});

Heads up — Express 5 changed this syntax to {} braces because they switched to path-to-regexp v8. If we’re on v5, use /users/:id/posts{/:postId}. For most production apps still on v4, the ? form works.

Wildcards

Use * for “match anything”:

app.get('/files/*', (req, res) => {
  // /files/a/b/c → req.params[0] === 'a/b/c'
  res.send(req.params[0]);
});

Handy for serving file paths or catch-all 404 routes.

Query strings — req.query

The bit after ? in the URL. Express parses it into an object:

app.get('/users', (req, res) => {
  const { role, limit = 10 } = req.query;
  res.json({ role, limit });
});

// GET /users?role=admin&limit=20
// → { "role": "admin", "limit": "20" }

Again — values are strings. limit=20 arrives as "20".

Repeated keys become arrays:

// GET /search?tag=node&tag=express
// req.query.tag === ['node', 'express']

Nested objects work too (qs library):

// GET /users?filter[role]=admin&filter[active]=true
// req.query.filter === { role: 'admin', active: 'true' }

When to use which

Think of it like this:

  • /users/:id — “I want the resource at this ID.” Params.
  • /users?role=admin&sort=name — “Filter/modify the result.” Query.

A common interview gotcha: “Should I use a query string or a path param for filtering?” — query. Path params identify; query params modify.

Validation reminder

req.params and req.query are user input. Validate everything. Use zod, joi, express-validator, or hand-rolled checks:

app.get('/users/:id', (req, res) => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id) || id <= 0) {
    return res.status(400).json({ error: 'Invalid id' });
  }
  // ... safe to query DB now
});

Skipping this is how SQL injection and NoSQL injection happen.


5

Router & Modular Routes

intermediate express router structure

Putting every route in one index.js works until it doesn’t — by route 50, the file is 800 lines and impossible to navigate. express.Router() is how we split routes into modules.

In simple language — a Router is a mini Express app. It has the same .get/.post/.use methods, but instead of starting a server it gets “mounted” onto the main app at some path prefix.

The basic pattern

// routes/users.js
const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  res.json([{ id: 1, name: 'Manish' }]);
});

router.get('/:id', (req, res) => {
  res.json({ id: req.params.id });
});

router.post('/', (req, res) => {
  res.status(201).json({ created: true });
});

module.exports = router;
// app.js
const express = require('express');
const usersRouter = require('./routes/users');

const app = express();
app.use(express.json());
app.use('/api/users', usersRouter);

app.listen(3000);

Now GET /api/users/42 lands in the router’s /:id handler. The mount path (/api/users) gets stripped before the router sees the URL.

Mounting tree
app
├── /api
│   ├── /users ─→ usersRouter
│   │   ├── GET    /         (list)
│   │   ├── GET    /:id      (get one)
│   │   └── POST   /         (create)
│   ├── /posts ─→ postsRouter
│   │   ├── GET    /
│   │   └── POST   /
│   └── /auth  ─→ authRouter
│       ├── POST /login
│       └── POST /logout
└── GET /healthz
    

Why this matters

  • Separation of concerns — one file per resource (users, posts, auth)
  • Per-router middleware — apply auth only to certain routes
  • Reusability — mount the same router under multiple paths
  • Testability — import the router directly and test in isolation

Per-router middleware

router.use() works just like app.use() but only affects that router:

// routes/admin.js
const router = express.Router();

const requireAdmin = (req, res, next) => {
  if (req.user?.role !== 'admin') return res.sendStatus(403);
  next();
};

router.use(requireAdmin);  // every route below requires admin

router.get('/stats', (req, res) => res.json({ users: 1000 }));
router.delete('/users/:id', (req, res) => res.sendStatus(204));

module.exports = router;

Mount it at /admin and now every /admin/* request runs requireAdmin first. Clean.

mergeParams — passing params down

A subtle one. If we have nested resources like /users/:userId/posts, the posts router doesn’t see :userId by default. We need mergeParams: true:

// routes/users.js
const router = express.Router();
const postsRouter = require('./posts');

router.use('/:userId/posts', postsRouter);
// routes/posts.js
const router = express.Router({ mergeParams: true });

router.get('/', (req, res) => {
  res.json({ userId: req.params.userId }); // works only with mergeParams
});

module.exports = router;

Without mergeParams, req.params.userId is undefined inside the posts router. Easy to get bitten by this.

A realistic layout

src/
├── app.js
├── routes/
│   ├── index.js          # mounts all sub-routers
│   ├── users.js
│   ├── posts.js
│   └── auth.js
├── middleware/
│   ├── auth.js
│   └── errorHandler.js
└── controllers/          # actual business logic
    ├── users.js
    └── posts.js
// routes/index.js
const express = require('express');
const router = express.Router();

router.use('/users', require('./users'));
router.use('/posts', require('./posts'));
router.use('/auth', require('./auth'));

module.exports = router;
// app.js
app.use('/api', require('./routes'));

Now app.js has one route line. Adding a new resource means creating one file and adding one router.use.

Routes vs controllers

A common pattern — the router defines URLs and delegates to a controller function:

// routes/users.js
const router = express.Router();
const ctrl = require('../controllers/users');

router.get('/', ctrl.list);
router.get('/:id', ctrl.get);
router.post('/', ctrl.create);

module.exports = router;

This keeps the router file readable as a “URL map” and pushes logic into testable controller functions. Worth doing once the codebase grows past a few files.


Middleware

6

Middleware Concept

intermediate express middleware next pipeline

If you understand middleware, you understand Express. Everything in Express — routing, body parsing, auth, error handling — is built on this one idea.

In simple language — middleware is a function that runs between the request coming in and the response going out. It gets the request and response objects, and a next function. It can:

  1. Run any code (logging, auth checks)
  2. Modify req or res (attach req.user, set headers)
  3. End the request (send a response itself)
  4. Call next() to pass to the next middleware

The whole Express app is a pipeline of these functions executed in order.

The signature

function middleware(req, res, next) {
  // ... do something
  next(); // pass to the next middleware
}

That’s it. Three arguments. Call next() when done.

A simple example

const express = require('express');
const app = express();

// Middleware 1: log every request
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();
});

// Middleware 2: attach a timestamp
app.use((req, res, next) => {
  req.requestTime = Date.now();
  next();
});

// Route handler (also middleware, just the last one)
app.get('/', (req, res) => {
  res.json({ at: req.requestTime });
});

app.listen(3000);

When GET / comes in:

  1. Logger runs, calls next()
  2. Timestamp middleware runs, calls next()
  3. Route handler runs, sends response
Middleware pipeline
req in
logger
next()→
body parser
next()→
auth
next()→
handler
res out
Any middleware can short-circuit by sending a response instead of calling next()

ORDER MATTERS (this is the #1 gotcha)

Express runs middleware in the order they’re registered. If body parser comes after the route, the route sees req.body === undefined:

// BROKEN
app.post('/users', (req, res) => {
  res.json(req.body); // undefined!
});
app.use(express.json()); // too late
// CORRECT
app.use(express.json());
app.post('/users', (req, res) => {
  res.json(req.body); // works
});

Rule of thumb — register middleware in this order:

  1. Logging
  2. Security (helmet, cors)
  3. Body parsing (express.json, express.urlencoded)
  4. Auth
  5. Routes
  6. 404 handler
  7. Error handler (last, always)

Three ways to use middleware

// 1. App-level — runs for every request
app.use(logger);

// 2. Path-specific — only for requests starting with /api
app.use('/api', apiMiddleware);

// 3. Route-specific — only for this one route
app.get('/admin', requireAuth, (req, res) => { ... });

Short-circuiting

A middleware can end the request itself by sending a response and NOT calling next():

const requireAuth = (req, res, next) => {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next(); // only continues if authorized
};

The return is critical — without it, both the response AND next() could fire, which causes “Cannot set headers after they are sent.”

Error-handling middleware

A special type — four arguments. Express recognizes it by arity:

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

To send an error into this, call next(err) with an argument:

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await db.findUser(req.params.id);
    if (!user) return next(new Error('Not found'));
    res.json(user);
  } catch (err) {
    next(err); // jumps to error middleware
  }
});

Always register the error handler last, after all routes.

Reusing middleware

Middleware is just a function — we can publish it to npm, import from anywhere, share across projects. That’s why the Express ecosystem is huge: cors, helmet, morgan, compression, cookie-parser, express-rate-limit are all just middlewares.

The mental model

“An Express app is an ordered list of functions that each get a chance to read/modify the request, send a response, or pass control to the next one.”

If you internalize that, the rest of Express falls out naturally — routes are just middlewares matched by path/method, error handlers are middlewares with 4 args, routers are sub-pipelines.


7

Built-in Middleware

beginner express middleware body-parser static

Express ships with a handful of built-in middleware functions. You’ll use these in nearly every app. They used to live in a separate body-parser package, but since Express 4.16 they’re bundled directly.

express.json()

Parses incoming requests with Content-Type: application/json and attaches the parsed body to req.body.

const express = require('express');
const app = express();

app.use(express.json());

app.post('/users', (req, res) => {
  console.log(req.body); // { name: 'Manish', age: 28 }
  res.status(201).json(req.body);
});

Without this middleware, req.body would be undefined for JSON POST/PUT requests. The Node http module gives us a raw stream — Express needs to read it, decode it, and JSON-parse it.

Common options:

app.use(express.json({
  limit: '1mb',     // max body size (default: 100kb)
  strict: true,     // only accept arrays/objects (default: true)
}));

The limit is important — without it, an attacker can flood your server with massive payloads. Set it as tight as your real use case allows.

express.urlencoded()

Parses application/x-www-form-urlencoded bodies — the format HTML forms submit by default.

app.use(express.urlencoded({ extended: true }));

app.post('/login', (req, res) => {
  const { email, password } = req.body;
  // ... handle login
});

The extended option:

  • extended: false — uses Node’s built-in querystring. Only string/array values.
  • extended: true — uses the qs library. Supports nested objects like user[name]=Manish.

For modern APIs that only accept JSON, you may not need this at all. Add it if you serve HTML forms.

express.static()

Serves static files (HTML, CSS, JS, images) from a directory:

app.use(express.static('public'));

Now GET /logo.png serves ./public/logo.png. GET / serves ./public/index.html if it exists.

We usually mount it at a path:

app.use('/assets', express.static('public'));
// /assets/logo.png → ./public/logo.png

Options worth knowing:

app.use(express.static('public', {
  maxAge: '1d',          // Cache-Control: max-age=86400
  etag: true,            // ETag header for revalidation
  index: 'home.html',    // default file (default: 'index.html')
}));

Production tip: in real deployments, static files should be served by nginx or a CDN, not Express. Express is fine for dev or low-traffic apps.

express.Router()

Already covered in detail in the Router & Modular Routes note. The TL;DR — creates a mini sub-app to organize routes across files.

const router = express.Router();
router.get('/', (req, res) => res.json([]));
module.exports = router;
The built-ins at a glance
Middleware Purpose When you need it
express.json()Parse JSON bodiesREST APIs (always)
express.urlencoded()Parse form bodiesHTML form submissions
express.static()Serve filesDev / small sites
express.Router()Modular routesAny non-trivial app

A realistic setup

A typical Express app’s middleware stack looks like this:

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');

const app = express();

// Security & logging
app.use(helmet());                                    // 3rd party
app.use(cors({ origin: 'https://pman47.cc' }));       // 3rd party
app.use(morgan('combined'));                          // 3rd party

// Body parsing (built-in)
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));

// Static files (built-in)
app.use('/assets', express.static('public'));

// Routes
app.use('/api', require('./routes'));

// Error handler (last)
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal' });
});

app.listen(3000);

That’s almost every production Express app’s skeleton. The built-ins cover body parsing and static files; everything else (CORS, security headers, logging, rate limiting) comes from npm.

These are common but not built-in:

  • cookie-parser — parses cookies into req.cookies
  • express-session — server-side sessions
  • multer — multipart/form-data (file uploads)

They were part of Express 3 but split out in Express 4. Install them separately when needed.


8

Third-party Middleware

intermediate express middleware npm

Express ships with a tiny core. Almost every real app stacks 4-6 third-party middleware packages to handle the boring stuff — parsing bodies, logging, security headers, CORS. In simple language: these are pre-built lego pieces we drop into app.use() and forget about.

Think of the request flowing through layers of an onion. Each middleware peels a layer, adds something, then hands off to the next.

Incoming Request
helmet  ·  security headers
cors  ·  allow cross-origin
morgan  ·  log the request
compression  ·  gzip the response
express.json  ·  parse body
cookie-parser  ·  read cookies
your route handler

The usual suspects

cors — adds Access-Control-Allow-* headers so browsers on different origins can call our API. Without it, a frontend on localhost:3000 calling an API on localhost:4000 gets blocked.

helmet — sets a bundle of security headers (CSP, HSTS, X-Frame-Options, etc.). One line, instant hardening.

morgan — logs every request: method, URL, status, response time. Great in dev, in prod we usually pipe it to a file or a real logger.

compression — gzips responses. Saves bandwidth on JSON payloads and HTML. The browser handles decompression.

cookie-parser — populates req.cookies from the Cookie header. Without it, req.cookies is undefined.

body-parser — parses JSON / URL-encoded request bodies into req.body. Since Express 4.16, this is built in as express.json() and express.urlencoded(), so we rarely install body-parser directly anymore.

Install and wire them up

npm install cors helmet morgan compression cookie-parser
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const compression = require("compression");
const cookieParser = require("cookie-parser");

const app = express();

// Security first — set headers before anything else
app.use(helmet());

// Allow our frontend to call us
app.use(cors({ origin: "https://app.example.com", credentials: true }));

// Log every request in dev, "combined" format in prod
app.use(morgan(process.env.NODE_ENV === "production" ? "combined" : "dev"));

// Gzip responses
app.use(compression());

// Parse JSON bodies (built into Express now)
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true }));

// Parse cookies
app.use(cookieParser(process.env.COOKIE_SECRET));

app.get("/api/users/:id", (req, res) => {
  console.log(req.cookies.session);   // from cookie-parser
  console.log(req.body);              // from express.json
  res.json({ id: req.params.id, name: "Manish" });
});

app.listen(3000);

Order matters

Helmet and cors go near the top — they set headers, and headers need to be set before res.send() flushes. Body parsers go before routes. Error middleware always goes last (covered in the next note).

The only thing to remember: request flows top-to-bottom, so whatever a route needs must be app.use()’d before that route is defined.

When to skip

  • Tiny internal service behind a VPN? Skip cors and helmet.
  • Pure JSON API? Skip view engines, skip express.urlencoded().
  • Behind nginx/Caddy with gzip already? Skip compression.

Don’t add middleware blindly — each one costs a few microseconds per request and adds an attack surface. Pick what we actually need.


9

Error-handling Middleware

intermediate express middleware errors

Error-handling middleware is just regular middleware with one quirk: it takes four arguments instead of three. Express specifically looks at the function’s arity (number of parameters) to decide “ah, this one handles errors.” Drop one of those four args and Express treats it as normal middleware. Weird but true.

In simple language: it’s the safety net at the bottom of our middleware stack. Anything that calls next(err) or throws inside an async route ends up here.

The signature

function errorHandler(err, req, res, next) {
  // err is whatever was passed to next(err) or thrown
  console.error(err);
  res.status(err.status || 500).json({ error: err.message });
}

Four params, in that exact order. Even if we don’t use next, we must declare it — otherwise Express sees 3 params and skips this for errors entirely.

Where it lives

app.use(helmet())
app.use(express.json())
app.get("/users", ...)
app.post("/users", ...)
404 handler (no route matched)
errorHandler(err, req, res, next)
must be LAST

If we put it before routes, it never gets triggered — Express has nothing to error on yet.

Triggering it

Three ways an error reaches our handler:

// 1. Explicit next(err)
app.get("/users/:id", (req, res, next) => {
  if (!isValidId(req.params.id)) {
    return next(new Error("Invalid user ID"));
  }
  // ...
});

// 2. Throw inside a sync handler — Express catches it
app.get("/boom", (req, res) => {
  throw new Error("Something exploded");
});

// 3. Async handler — must call next(err) or use a wrapper
app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.findUser(req.params.id);
    res.json(user);
  } catch (err) {
    next(err);   // throw won't work here, we must call next
  }
});

Express 5 (in beta as of 2026) automatically catches async errors. Until we’re on 5, the try/catch (or an asyncHandler wrapper) is mandatory.

A realistic error handler

class AppError extends Error {
  constructor(message, status = 500) {
    super(message);
    this.status = status;
  }
}

// 404 — no route matched
app.use((req, res, next) => {
  next(new AppError(`Route ${req.path} not found`, 404));
});

// The actual error handler — last middleware in the chain
app.use((err, req, res, next) => {
  const status = err.status || 500;
  const message = status === 500 && process.env.NODE_ENV === "production"
    ? "Internal server error"
    : err.message;

  // Log everything server-side
  req.log?.error({ err, path: req.path }, "request failed");

  res.status(status).json({
    error: message,
    ...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
  });
});

Notice: we hide stack traces and 500 messages in production. Leaking err.message to clients is how attackers learn we use Postgres on port 5432.

Async wrapper trick

Wrapping every route in try/catch gets tedious. A 3-line helper fixes it:

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) throw new AppError("User not found", 404);
  res.json(user);
}));

The express-async-errors package monkey-patches this globally if we don’t want a wrapper.

Gotchas

  • Already sent the response? Express won’t override headers — check res.headersSent and call next(err) to let the default handler close the socket.
  • Multiple error handlers are allowed — Express runs them in order, and we can call next(err) to pass to the next one.
  • Don’t console.log(err) in production — use a real logger so errors are searchable.

Request & Response

10

Request Object

beginner express request

req is the inbox. Whatever the client sent — URL params, JSON body, headers, cookies — Express parses it (with a little help from middleware) and parks it on this object. In simple language: req is everything we know about who’s calling and what they want.

Think of an HTTP request like a letter: there’s an address (URL), a return address (IP), some envelope notes (headers), and the actual letter inside (body). Express splits all of that across different properties on req.

req
URL bits
params, query, path
Payload
body
Metadata
headers, cookies
Network
ip, method, protocol

req.params — bits from the URL path

When we define a route with :something, Express captures it.

app.get("/users/:userId/posts/:postId", (req, res) => {
  // GET /users/42/posts/7
  req.params.userId;  // "42"
  req.params.postId;  // "7"
});

Params are always strings. If we need a number, parseInt(req.params.userId, 10).

req.query — the ?key=value part

// GET /search?q=express&limit=10&tags=node&tags=web
app.get("/search", (req, res) => {
  req.query.q;       // "express"
  req.query.limit;   // "10"  (string, not number)
  req.query.tags;    // ["node", "web"] — repeated keys become arrays
});

Express uses the qs library by default, so nested queries like ?filter[age]=30 parse into { filter: { age: "30" } }. Set app.set("query parser", "simple") for the basic version.

req.body — the request payload

We get nothing here until we install body-parsing middleware:

app.use(express.json());                          // for application/json
app.use(express.urlencoded({ extended: true })); // for form posts

app.post("/users", (req, res) => {
  // POST /users with body: {"name": "Manish", "email": "m@x.com"}
  req.body.name;   // "Manish"
  req.body.email;  // "m@x.com"
});

Without express.json(), req.body is undefined. This is the #1 “why is body empty” gotcha.

req.headers — request headers (lowercased)

app.get("/me", (req, res) => {
  req.headers["authorization"];  // "Bearer eyJhbGc..."
  req.headers["user-agent"];     // browser string
  req.headers["content-type"];   // "application/json"

  // Shortcut for any header
  req.get("Authorization");      // same as above, case-insensitive
});

All header names are lowercased — req.headers.Authorization returns undefined. Use req.get() if we want case-insensitive lookup.

const cookieParser = require("cookie-parser");
app.use(cookieParser());

app.get("/dashboard", (req, res) => {
  req.cookies.session;  // "abc123..."
});

Without cookie-parser, cookies live in req.headers.cookie as one long string we’d have to parse ourselves.

req.ip — the client’s IP

app.get("/whoami", (req, res) => {
  res.json({ ip: req.ip });
});

Behind a proxy (nginx, Caddy, Cloudflare), req.ip shows the proxy’s IP, not the real client. Fix by trusting the proxy:

app.set("trust proxy", 1);  // trust first proxy in chain
// Now req.ip reads from X-Forwarded-For

req.path vs req.url vs req.originalUrl

// App mounted at /api, request to /api/users?active=true
req.path;          // "/users"
req.url;           // "/users?active=true"
req.originalUrl;   // "/api/users?active=true"  ← full URL before mount stripping

req.originalUrl is the one we want for logging — it shows what the client actually requested.

Bonus useful ones

req.method;     // "GET", "POST", etc.
req.protocol;   // "http" or "https"
req.hostname;   // "api.example.com"
req.secure;     // true if https
req.xhr;        // true if X-Requested-With: XMLHttpRequest

Putting it all together — a logging middleware:

app.use((req, res, next) => {
  console.log(`${req.method} ${req.originalUrl} from ${req.ip}`);
  next();
});

That’s most of what we’ll ever need from req.


11

Response Object

beginner express response

res is the outbox. Whatever we want to send back — JSON, HTML, a file, a redirect — we do it through res. In simple language: req is what came in, res is what we send out.

The golden rule: we can only send one response per request. Call res.send() twice and Express throws “Cannot set headers after they are sent.” Always return after sending.

res — what we send back
Send body
.send() .json() .sendFile() .render()
Set status
.status() .sendStatus()
Set headers
.set() .type() .cookie()
Navigate
.redirect() .location()

res.send() — the universal sender

Sends whatever we give it and sets Content-Type based on the type.

app.get("/text", (req, res) => res.send("hello"));           // text/html
app.get("/obj",  (req, res) => res.send({ ok: true }));      // application/json
app.get("/buf",  (req, res) => res.send(Buffer.from("hi"))); // application/octet-stream

Express figures out the content type. For JSON specifically, prefer res.json().

res.json() — for APIs

app.get("/api/users/:id", (req, res) => {
  res.json({ id: req.params.id, name: "Manish" });
});

Same as res.send() for objects, but it also handles null and primitives correctly as JSON. Use this for every JSON API endpoint — it’s clearer intent.

{ "id": "42", "name": "Manish" }

res.status() — set the status code

Returns res, so we chain it:

app.post("/users", (req, res) => {
  if (!req.body.email) {
    return res.status(400).json({ error: "email is required" });
  }
  const user = createUser(req.body);
  res.status(201).json(user);   // 201 Created
});

Defaults to 200. For empty responses, res.sendStatus(204) sets status AND sends the standard status text.

res.redirect() — send the browser elsewhere

app.get("/old-page", (req, res) => {
  res.redirect(301, "/new-page");   // 301 = permanent
});

app.post("/login", (req, res) => {
  // ...auth...
  res.redirect("/dashboard");        // defaults to 302
});

301 is permanent (browser caches it forever), 302/303 are temporary. For POST-redirect-GET pattern, use 303.

res.sendFile() — stream a file from disk

const path = require("path");

app.get("/download/report.pdf", (req, res) => {
  res.sendFile(path.join(__dirname, "files", "report.pdf"));
});

The path must be absolute (or pass { root: ... }). Express sets Content-Type based on file extension and streams it efficiently. For forced download:

app.get("/download/:file", (req, res) => {
  res.download(path.join(__dirname, "files", req.params.file));
  // Sets Content-Disposition: attachment so browser saves instead of viewing
});

res.set() — set headers

res.set("X-Powered-By", "Coffee");
res.set({ "Cache-Control": "no-store", "X-Request-Id": "abc123" });

// Shortcut for content type
res.type("text/csv");
res.type("json");   // application/json

Headers must be set before res.send(). After we send, headers are locked.

Cookies

res.cookie("session", "abc123", {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 24 * 60 * 60 * 1000,  // 1 day in ms
});

res.clearCookie("session");

httpOnly prevents JS access (XSS protection), secure requires HTTPS, sameSite blocks CSRF.

Chaining — everything returns res

This is the Express magic — every setter returns res, so we chain:

res
  .status(201)
  .set("Location", `/users/${user.id}`)
  .type("json")
  .json(user);

Reads top-to-bottom like a recipe.

A realistic endpoint

app.post("/api/users", async (req, res, next) => {
  try {
    if (!req.body.email) {
      return res.status(400).json({ error: "email required" });
    }

    const user = await db.createUser(req.body);

    res
      .status(201)
      .set("Location", `/api/users/${user.id}`)
      .json(user);
  } catch (err) {
    next(err);
  }
});

Status, location header, JSON body — all in one chain. That’s res in a nutshell.


12

Static Files & Templating

beginner express static templating

Not every Express app is a JSON API. Sometimes we serve a full website — HTML pages, CSS files, images, client-side JS. Express has two built-in tools for this: express.static for files that don’t change, and view engines for HTML we generate from data.

In simple language: static = “send this file as-is”, templating = “fill in the blanks, then send.”

Static files — express.static

const path = require("path");

app.use(express.static(path.join(__dirname, "public")));

Now anything in public/ is reachable directly:

public/
  styles.css      →  GET /styles.css
  js/app.js       →  GET /js/app.js
  images/logo.png →  GET /images/logo.png
  favicon.ico     →  GET /favicon.ico

Notice there’s no /public prefix in the URL — Express strips the folder name. If we want a prefix:

app.use("/assets", express.static("public"));
// Now: /assets/styles.css → public/styles.css

Useful options

app.use(express.static("public", {
  maxAge: "1d",        // Cache-Control: max-age=86400
  etag: true,          // send ETag for conditional GETs
  index: "home.html",  // default file for directories (instead of index.html)
  dotfiles: "ignore",  // skip files starting with . (like .env)
}));

In production we usually slap a CDN or nginx in front of static assets, but express.static is perfect for dev or small apps.

Templating — server-rendered HTML

A view engine takes a template file with placeholders and data, and spits out HTML. Express supports many — the popular ones are EJS, Pug, and Handlebars. Pick one based on syntax preference.

EJS
HTML + <%= %>
familiar, no learning curve
Pug
indentation-based
terse but unique syntax
Handlebars
HTML + {{ }}
logic-less, popular

Setting up EJS

npm install ejs
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));  // default is ./views

app.get("/users/:id", async (req, res) => {
  const user = await db.findUser(req.params.id);
  res.render("user-profile", { user, title: "Profile" });
});

res.render(template, data) finds views/user-profile.ejs, runs it with the data, sends HTML.

views/user-profile.ejs

<!DOCTYPE html>
<html>
  <head><title><%= title %></title></head>
  <body>
    <h1>Hello, <%= user.name %></h1>
    <% if (user.isAdmin) { %>
      <a href="/admin">Admin Panel</a>
    <% } %>
    <ul>
      <% user.posts.forEach(post => { %>
        <li><%= post.title %></li>
      <% }); %>
    </ul>
  </body>
</html>

Three EJS tags to remember:

  • <%= value %> — print value, escaped (XSS-safe)
  • <%- value %> — print value, raw HTML (use sparingly)
  • <% code %> — execute JS, don’t print

Pug version

npm install pug
app.set("view engine", "pug");
doctype html
html
  head
    title= title
  body
    h1 Hello, #{user.name}
    if user.isAdmin
      a(href="/admin") Admin Panel
    ul
      each post in user.posts
        li= post.title

No closing tags, indentation defines nesting. Some love it, some hate it.

Handlebars version

npm install express-handlebars
const { engine } = require("express-handlebars");
app.engine("handlebars", engine());
app.set("view engine", "handlebars");
<h1>Hello, {{user.name}}</h1>
{{#if user.isAdmin}}
  <a href="/admin">Admin Panel</a>
{{/if}}
<ul>
  {{#each user.posts}}
    <li>{{this.title}}</li>
  {{/each}}
</ul>

Logic-less by design — keeps templates clean and forces business logic into the route handler.

Putting it all together

A typical server-rendered Express app:

const express = require("express");
const path = require("path");

const app = express();

// Serve CSS/JS/images
app.use(express.static(path.join(__dirname, "public")));

// HTML pages
app.set("view engine", "ejs");

app.get("/", (req, res) => {
  res.render("home", { title: "Welcome" });
});

app.get("/users/:id", async (req, res) => {
  const user = await db.findUser(req.params.id);
  res.render("user-profile", { user });
});

app.listen(3000);

For modern apps we’d usually go SPA (React/Vue) + JSON API. But for blogs, dashboards, or admin panels, server-rendered templates are still the fastest way to ship.


Security

13

CORS

intermediate express cors security

CORS stands for Cross-Origin Resource Sharing. In simple language: the browser refuses to let JavaScript on app.example.com read responses from api.example.com unless the API explicitly says “yes, I trust that origin.” It’s the browser protecting users, not the server protecting itself.

A common misconception: CORS errors are NOT a server error. The server happily returns 200 OK. The browser then sees no Access-Control-Allow-Origin header and throws the response away before our JS can see it.

What counts as cross-origin

An “origin” is the combo of scheme + host + port. Anything different = cross-origin.

https://app.example.com:443   ←  origin A
http://app.example.com:443    ←  different (scheme)
https://api.example.com:443   ←  different (host)
https://app.example.com:8080  ←  different (port)

Two flavors of CORS requests

The browser splits cross-origin requests into two buckets:

Simple request
GET / POST / HEAD
+ only safe headers
+ Content-Type: form or text/plain

Browser sends it directly, then checks response headers.
Preflighted request
PUT / DELETE / PATCH
or custom headers
or Content-Type: application/json

Browser sends OPTIONS first to ask permission.

Any modern JSON API hits the preflight path — Content-Type: application/json alone triggers it.

The preflight dance

1. Browser: OPTIONS /api/users
   Origin: https://app.example.com
   Access-Control-Request-Method: POST
   Access-Control-Request-Headers: content-type
2. Server: 204 No Content
   Access-Control-Allow-Origin: https://app.example.com
   Access-Control-Allow-Methods: POST
   Access-Control-Allow-Headers: content-type
3. Browser: POST /api/users (the real request)

The preflight is cacheable (Access-Control-Max-Age), so we don’t pay this cost on every request.

Using the cors middleware

npm install cors

The lazy version — allow everything (fine for public APIs, dangerous if we use cookies):

const cors = require("cors");
app.use(cors());   // Access-Control-Allow-Origin: *

The grown-up version — only allow specific origins:

app.use(cors({
  origin: ["https://app.example.com", "https://admin.example.com"],
  methods: ["GET", "POST", "PUT", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  credentials: true,    // allow cookies/auth headers
  maxAge: 86400,        // cache preflight for 1 day
}));

Dynamic origin check — useful for whitelisting from a DB or env var:

const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",");

app.use(cors({
  origin: (origin, callback) => {
    // origin is undefined for same-origin and non-browser clients (curl)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error("Not allowed by CORS"));
    }
  },
  credentials: true,
}));

The credentials: true gotcha

If our frontend sends cookies or auth headers cross-origin (fetch(url, { credentials: "include" })), we need:

  1. credentials: true on the server.
  2. Access-Control-Allow-Origin must be a specific origin, not *. Wildcard + credentials is forbidden by the spec.
// This combo will FAIL in the browser:
app.use(cors({ origin: "*", credentials: true }));   // wildcard + creds = no

// This works:
app.use(cors({ origin: "https://app.example.com", credentials: true }));

Per-route CORS

We can apply CORS to specific routes instead of globally:

const publicCors = cors({ origin: "*" });
const privateCors = cors({ origin: "https://admin.example.com", credentials: true });

app.get("/api/health", publicCors, (req, res) => res.json({ ok: true }));
app.use("/api/admin", privateCors, adminRouter);

Common errors and what they mean

  • “No ‘Access-Control-Allow-Origin’ header” — we forgot the middleware, or the origin isn’t whitelisted.
  • “…header field authorization is not allowed” — add Authorization to allowedHeaders.
  • “…wildcard cannot be used with credentials” — fix listed above.
  • Preflight returns 404 — our app routes OPTIONS to a real handler. cors() should handle it before our routes do.

CORS isn’t security — anyone can curl our API. It’s just the browser respecting same-origin policy for its users.


14

Helmet

intermediate express security headers

Helmet is app.use(helmet()) — one line, fifteen security headers. In simple language: it’s a checklist of HTTP headers that browsers respect to block common attacks. We don’t need to remember each header; helmet sets sane defaults.

Think of it like the default settings on a bank vault — we can tune individual locks, but the out-of-the-box config already blocks 90% of casual attacks.

npm install helmet
const helmet = require("helmet");
app.use(helmet());

Done. Let’s look at what just happened.

What helmet sets (the defaults)

Content-Security-Policy
whitelist what scripts/styles/images can load — blocks XSS
Strict-Transport-Security (HSTS)
force HTTPS for this domain, no downgrade attacks
X-Frame-Options
block iframe embedding — stops clickjacking
X-Content-Type-Options: nosniff
browser must trust Content-Type, no guessing
Referrer-Policy
don't leak full URLs in Referer header
X-DNS-Prefetch-Control, X-Download-Options, etc.
misc browser hardening

Content-Security-Policy (CSP) — the big one

CSP tells the browser “only load scripts from these places.” If an attacker injects <script src="evil.com/x.js">, the browser refuses to fetch it.

Helmet’s default CSP is strict — only same-origin assets, no inline scripts. That breaks most apps that use Google Analytics, Stripe, or inline event handlers. So we customize:

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://js.stripe.com", "https://www.googletagmanager.com"],
      styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.stripe.com"],
      frameSrc: ["https://js.stripe.com"],
    },
  },
}));

In simple language: each directive lists allowed sources for a resource type. 'self' means “same origin as the page.” Anything not whitelisted gets blocked and logged in the console.

For pure JSON APIs (no HTML rendered), CSP doesn’t matter much — there’s no DOM to inject into. We can disable it:

app.use(helmet({ contentSecurityPolicy: false }));

HSTS — force HTTPS

Strict-Transport-Security: max-age=15552000; includeSubDomains

After the browser sees this once, it refuses to talk to our domain over HTTP for the next ~6 months. Even if a user types http://app.example.com, the browser silently upgrades to https://.

Crucial for any login flow. Without HSTS, a coffee-shop attacker can downgrade the connection and steal cookies.

Only enable HSTS once we’re sure HTTPS works everywhere — once browsers cache it, we’re committed. Test with a short maxAge first.

X-Frame-Options — clickjacking

X-Frame-Options: SAMEORIGIN

Without this, an attacker can put our /transfer-money page in a transparent iframe on cute-puppies.com and trick users into clicking buttons. SAMEORIGIN says “only my own pages can iframe me.” DENY means no iframing at all.

CSP’s frame-ancestors directive supersedes this in modern browsers, but helmet sets both for older browser support.

X-Content-Type-Options: nosniff

X-Content-Type-Options: nosniff

Old browsers used to “sniff” content — if we sent a .txt file but it looked like JS, the browser might execute it. nosniff says “trust the Content-Type header and nothing else.” Closes a whole class of upload-based XSS.

A realistic setup

const express = require("express");
const helmet = require("helmet");

const app = express();

if (process.env.NODE_ENV === "production") {
  app.use(helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "https://js.stripe.com"],
        imgSrc: ["'self'", "data:", "https://cdn.example.com"],
      },
    },
    hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  }));
} else {
  // Looser CSP in dev so HMR / source maps work
  app.use(helmet({ contentSecurityPolicy: false }));
}

What helmet doesn’t do

  • Doesn’t validate input. SQL injection, prototype pollution — still our job.
  • Doesn’t rate-limit. Use express-rate-limit for that.
  • Doesn’t sanitize HTML. If we render user content, use a sanitizer like DOMPurify.
  • Doesn’t replace authentication. Just hardens the transport.

The headers are belt-and-suspenders defense. Helmet makes them painless — there’s basically no reason not to use it in any Express app that serves browsers.


15

Rate Limiting

intermediate express security rate-limiting redis

Rate limiting caps how many requests a single client (usually identified by IP) can fire at our API in a given window. Without it, one bored attacker with a while true loop can melt our login endpoint or rack up our OpenAI bill.

In simple language: it’s a bouncer that counts how many times you’ve walked into the club tonight and stops letting you in after the limit.

Why we need it

  • Stop brute-force attacks on /login
  • Prevent scraping
  • Protect expensive endpoints (LLM calls, image processing)
  • Save our database from accidental client bugs (a buggy mobile app hammering our server)

The algorithms

Fixed Window
100 req per minute, counter resets at :00
[####------] 4/100
Bursty: can do 200 across minute boundary
Sliding Window
Counts requests in last 60s, rolling
[##-##-####] last 60s
Smoother, no edge bursts
Token Bucket
Bucket holds N tokens, refills at rate R
Bucket: [###--] 3/5
Allows bursts, smooth average

Think of it like this: fixed window is a counter on the wall that resets every hour. Sliding window keeps a rolling list of timestamps. Token bucket is a leaky bucket of coins — you spend one per request, and coins drip back in over time.

Basic setup with express-rate-limit

npm install express-rate-limit
import rateLimit from "express-rate-limit";

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 100,               // 100 requests per IP per window
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: { error: "Too many requests, slow down." },
});

app.use("/api", apiLimiter);

Now any IP hitting /api/* more than 100 times in 15 minutes gets a 429 Too Many Requests.

Tighter limits for sensitive routes

The login endpoint is the juiciest target. Lock it down hard.

const loginLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 5,
  skipSuccessfulRequests: true, // only count failed logins
  keyGenerator: (req) => req.body.email || req.ip,
});

app.post("/login", loginLimiter, loginHandler);

skipSuccessfulRequests is gold for login — a legit user who typed their password right shouldn’t get punished. Only bad attempts count.

In-memory vs Redis store

By default, express-rate-limit keeps counters in memory. That works fine for a single-process server. The moment we scale to multiple Node instances behind a load balancer, the math breaks — each instance has its own counter, so an attacker effectively gets limit × instances.

Enter Redis.

npm install rate-limit-redis ioredis
import { RedisStore } from "rate-limit-redis";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

const limiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 100,
  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),
});

Now all instances share the same counter. One source of truth.

Trade-offs

StoreProsCons
In-memoryZero deps, fastBreaks across instances, lost on restart
RedisShared state, survives restartsNetwork hop, infra to manage

Gotchas

  • Behind a proxy? Set app.set("trust proxy", 1) so req.ip reads the real client IP from X-Forwarded-For, not your load balancer’s IP.
  • Per-user vs per-IP: for authenticated routes, key on user ID instead of IP — otherwise a whole office NAT shares one bucket.
  • Don’t rate-limit health checks: your monitoring will hate you.

Auth & Validation

16

Authentication Patterns

intermediate express auth passport jwt

Authentication in Express boils down to a recurring pattern: a route handler that verifies “who is this user?” before letting them touch protected stuff. We have two big choices for how to wire that up — Passport.js (batteries-included) or roll-our-own middleware.

In simple language: Passport is like buying an IKEA shelf — pick a strategy, snap it together. Custom middleware is woodworking from scratch. Both work, the tradeoff is flexibility vs control.

The auth flow at a glance

Login → Token → Protected Request
1. Client → POST /login { email, pwd }
2. Server: verify pwd, sign JWT, return tokens
3. Client stores access + refresh tokens
4. Client → GET /me Authorization: Bearer <access>
5. Middleware verifies → attaches req.user
When access expires: POST /refresh → new access

Option 1: Passport.js

Passport is a middleware layer that abstracts auth into “strategies”. One strategy for local username/password, one for Google OAuth, one for JWT, etc. We pick what we need.

npm install passport passport-local passport-jwt
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";

passport.use(new LocalStrategy(
  { usernameField: "email" },
  async (email, password, done) => {
    const user = await db.users.findByEmail(email);
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      return done(null, false, { message: "Invalid credentials" });
    }
    return done(null, user);
  }
));

passport.use(new JwtStrategy({
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
  secretOrKey: process.env.JWT_SECRET,
}, async (payload, done) => {
  const user = await db.users.findById(payload.sub);
  return user ? done(null, user) : done(null, false);
}));

app.post("/login",
  passport.authenticate("local", { session: false }),
  (req, res) => {
    const token = jwt.sign({ sub: req.user.id }, process.env.JWT_SECRET, { expiresIn: "15m" });
    res.json({ token });
  }
);

app.get("/me", passport.authenticate("jwt", { session: false }), (req, res) => {
  res.json({ user: req.user });
});

The win: 500+ strategies for OAuth providers (Google, GitHub, Facebook). The cost: a learning curve, and the API feels dated.

Option 2: Custom middleware

For most APIs we control end-to-end, a 20-line middleware beats pulling in Passport. Honest take: I reach for this 80% of the time.

import jwt from "jsonwebtoken";

export function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing token" });
  }
  try {
    const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
  }
}

app.get("/me", requireAuth, (req, res) => {
  res.json({ userId: req.user.id });
});

Want role-based access? Compose another middleware.

const requireRole = (role) => (req, res, next) =>
  req.user?.role === role ? next() : res.status(403).end();

app.delete("/users/:id", requireAuth, requireRole("admin"), deleteUser);

Refresh tokens

Short-lived access tokens (15 min) limit damage if one leaks. But forcing users to log in every 15 minutes is hell. Refresh tokens fix this — a long-lived (7-30 day) token that only does one thing: get new access tokens.

app.post("/login", async (req, res) => {
  const user = await verifyCredentials(req.body);
  const accessToken = jwt.sign({ sub: user.id }, ACCESS_SECRET, { expiresIn: "15m" });
  const refreshToken = jwt.sign({ sub: user.id }, REFRESH_SECRET, { expiresIn: "30d" });

  // store refresh token hash in DB so we can revoke it
  await db.refreshTokens.insert({ userId: user.id, tokenHash: sha256(refreshToken) });

  res.json({ accessToken, refreshToken });
});

app.post("/refresh", async (req, res) => {
  const { refreshToken } = req.body;
  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET);
    const stored = await db.refreshTokens.findByHash(sha256(refreshToken));
    if (!stored) return res.status(401).end(); // revoked

    const accessToken = jwt.sign({ sub: payload.sub }, ACCESS_SECRET, { expiresIn: "15m" });
    res.json({ accessToken });
  } catch {
    res.status(401).end();
  }
});

Key idea: refresh tokens live in the DB so we can revoke them on logout. Access tokens are stateless and trusted as long as the signature checks out.

Which to pick?

  • Building an API for your own clients? Custom middleware.
  • Need Google/GitHub/SSO login? Passport, no contest.
  • Mixing both? Totally fine — Passport for OAuth, custom JWT middleware for your API routes.

17

Sessions vs JWT

intermediate express auth sessions jwt redis

This is the interview question. “Sessions or JWT, which would you use?” The honest answer is “depends” — but you better know why. Both solve the same problem: keeping a user logged in across requests. They just take opposite philosophies.

In simple language: sessions are like a coat check at a restaurant — you get a ticket number, the actual coat (your identity) lives with the host. JWT is like a wristband at a festival — everything you need is on the band itself, no central list.

Side-by-side

Sessions (Stateful)
Server holds the truth
Cookie: sid=abc123

Server looks up
Redis[abc123] = { userId: 42 }

req.user = ...
+ Easy to revoke (delete key)
- Needs shared store (Redis)
JWT (Stateless)
Token carries the truth
Header: Bearer eyJ...

Server verifies signature
payload = { userId: 42 }

req.user = ...
+ No DB hit, scales horizontally
- Can't revoke without extra work

Sessions with express-session + Redis

npm install express-session connect-redis ioredis
import session from "express-session";
import { RedisStore } from "connect-redis";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

app.use(session({
  store: new RedisStore({ client: redis }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,        // HTTPS only
    sameSite: "lax",
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  },
}));

app.post("/login", async (req, res) => {
  const user = await verifyCredentials(req.body);
  req.session.userId = user.id;       // server stores this in Redis
  res.json({ ok: true });             // browser gets a sid cookie
});

app.get("/me", (req, res) => {
  if (!req.session.userId) return res.status(401).end();
  res.json({ userId: req.session.userId });
});

app.post("/logout", (req, res) => {
  req.session.destroy(() => res.json({ ok: true })); // gone from Redis
});

What’s happening: the cookie holds an opaque session ID. The actual user data lives in Redis. Every request, Express pulls from Redis and hydrates req.session.

JWT with jsonwebtoken

npm install jsonwebtoken
import jwt from "jsonwebtoken";

app.post("/login", async (req, res) => {
  const user = await verifyCredentials(req.body);
  const token = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: "1h" }
  );
  res.json({ token });
});

app.get("/me", (req, res) => {
  const token = req.headers.authorization?.slice(7);
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    res.json({ userId: payload.sub });
  } catch {
    res.status(401).end();
  }
});

No Redis. No DB lookup. The token IS the proof.

A decoded payload looks like:

{
  "sub": "user_42",
  "role": "admin",
  "iat": 1716700000,
  "exp": 1716703600
}

The tradeoffs

ConcernSessionsJWT
StateServer (Redis/DB)Client (the token)
RevocationTrivial — delete the keyHard — token is valid till expiry
Horizontal scalingNeed shared storeFree, no shared state
Token sizeSmall (just sid)Larger (encoded JSON, ~300-1000 bytes)
Mobile appsCookies are awkwardBearer tokens are clean
CSRFVulnerable (cookies auto-sent)Safer (manual Authorization header)
XSShttpOnly cookies protect itlocalStorage tokens are exposed
Logout-everywhereDelete sessions for userNeed token blocklist or rotate secret

The honest verdict

  • Server-rendered web app, same domain? Sessions. Cookies just work, revocation is free.
  • Mobile + web + third-party API consumers? JWT, often with short access + long refresh tokens.
  • Microservices? JWT, so service B doesn’t need to call the auth service on every request.
  • Banking-grade app where you must instantly revoke? Sessions, or JWT with a Redis-backed blocklist (which… is basically sessions again).

The hybrid (what most production apps actually do)

Use JWT access tokens (15 min) for stateless API calls, plus a refresh token stored server-side. Best of both: stateless requests, but revocation possible via the refresh token store. Covered in the Authentication Patterns note.

Common interview follow-ups

  • “Where do you store the JWT on the client?”httpOnly cookie if same-site, in-memory for SPAs. Avoid localStorage if you can — XSS reads it.
  • “How do you log a user out of all devices?” — sessions: delete all their sessions in Redis. JWT: rotate the user’s signing secret or maintain a jti blocklist.
  • “Is JWT inherently less secure?” — no, but it’s easier to misuse. People put PII in payloads (it’s just base64, not encrypted), forget to set expiresIn, or use none algorithm.

18

Request Validation

intermediate express validation zod joi

Never trust the client. Ever. Validation is the wall between “user typed bad data” and “production database has nullable columns full of "undefined" strings”. Express gives us zero validation out of the box — we pick a library.

In simple language: validation says “is this request shaped the way I expect?” before our handler touches it. Sanitization goes further — it cleans up the input (trims whitespace, strips HTML, normalizes emails).

The three contenders

express-validator
Chain-based middleware
Express-native, sanitize built-in, verbose
Zod
TypeScript-first schemas
Infers TS types from schema, modern API
Joi
Battle-tested, OG
Came from Hapi, rich rule set, no TS inference

express-validator

The Express-native option. Chain validators per field, then check validationResult in the handler.

npm install express-validator
import { body, validationResult } from "express-validator";

app.post(
  "/users",
  body("email").isEmail().normalizeEmail(),
  body("password").isLength({ min: 8 }).withMessage("8 chars min"),
  body("age").optional().isInt({ min: 13, max: 120 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // req.body is sanitized in place
    createUser(req.body);
    res.status(201).end();
  }
);

normalizeEmail() is sanitization — it lowercases, strips dots from Gmail, etc. That’s the “extra” express-validator brings.

Zod

The hot pick for TypeScript projects. Schemas double as TypeScript types — define once, get both runtime checking and compile-time safety.

npm install zod
import { z } from "zod";

const CreateUserSchema = z.object({
  email: z.string().email().toLowerCase().trim(),
  password: z.string().min(8),
  age: z.number().int().min(13).max(120).optional(),
});

// type CreateUser = z.infer<typeof CreateUserSchema>;

function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({ errors: result.error.flatten() });
    }
    req.body = result.data; // parsed + transformed
    next();
  };
}

app.post("/users", validate(CreateUserSchema), (req, res) => {
  // req.body is now typed as CreateUser
  createUser(req.body);
  res.status(201).end();
});

The killer feature is z.infer — your schema IS your type. No drift between them, ever.

Joi

The veteran. Used by Hapi, still widely deployed. Same idea as Zod but predates it and has no TS inference.

npm install joi
import Joi from "joi";

const schema = Joi.object({
  email: Joi.string().email().lowercase().required(),
  password: Joi.string().min(8).required(),
  age: Joi.number().integer().min(13).max(120),
});

app.post("/users", (req, res) => {
  const { error, value } = schema.validate(req.body, { abortEarly: false });
  if (error) {
    return res.status(400).json({ errors: error.details });
  }
  createUser(value);
  res.status(201).end();
});

Validating query and params, not just body

Don’t forget — query strings and URL params are user input too.

import { z } from "zod";

const ListUsersQuery = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().trim().max(50).optional(),
});

app.get("/users", (req, res) => {
  const query = ListUsersQuery.parse(req.query);
  // query.page is a real number now, not "2"
  res.json(listUsers(query));
});

z.coerce.number() is gold here — query strings are always strings, so we coerce "5"5.

Sanitization vs validation

The only difference is: validation rejects, sanitization transforms.

  • body("email").isEmail() → reject if not an email
  • body("email").normalizeEmail() → lowercase, strip Gmail dots
  • body("comment").trim().escape() → strip whitespace, escape <script> tags

Don’t rely on sanitization alone for XSS — output-encode at render time. But it’s a useful belt-and-suspenders.

Which to pick?

  • TypeScript project? Zod. The type inference is too good to skip.
  • JavaScript-only Express app? express-validator or Joi, taste preference.
  • Already using a framework that ships Joi (Hapi-style)? Stay with it.

My default for new code: Zod. The schema-as-type trick eliminates a whole category of “the DB has a string but the type says number” bugs.

Centralize it

Don’t sprinkle validation across handlers. Build a validate(schema) middleware once, then every route is one line:

app.post("/users", validate(CreateUserSchema), createUserHandler);
app.patch("/users/:id", validate(UpdateUserSchema), updateUserHandler);

Clean handlers, schemas live next to the route, no duplication.


Production

19

File Uploads (multer)

intermediate express multer uploads multipart

Express’s express.json() middleware can’t parse file uploads. Files come in as multipart/form-data, a format designed for mixed text + binary data. Multer is the standard tool for parsing it.

In simple language: a multipart request is a bunch of “parts” glued together with a boundary string, each part has its own headers. Multer carves it up so we get clean req.file and req.body objects.

The upload pipeline

Request → Multer → Handler
Client POST /upload
Content-Type: multipart/form-data; boundary=----abc
[avatar binary][caption: "Vacation"]

multer.single("avatar") parses the stream

req.file = { fieldname, originalname, mimetype, size, path/buffer }
req.body = { caption: "Vacation" }

Basic setup

npm install multer
import multer from "multer";

const upload = multer({ dest: "uploads/" });

app.post("/profile", upload.single("avatar"), (req, res) => {
  console.log(req.file);
  // {
  //   fieldname: 'avatar',
  //   originalname: 'cat.jpg',
  //   mimetype: 'image/jpeg',
  //   destination: 'uploads/',
  //   filename: '8d7f...',
  //   path: 'uploads/8d7f...',
  //   size: 50234
  // }
  console.log(req.body); // other form fields
  res.json({ uploaded: req.file.filename });
});

upload.single("avatar") says “expect one file in a form field called avatar”. For multiple files: upload.array("photos", 5) or upload.fields([{ name: "avatar", maxCount: 1 }, { name: "docs", maxCount: 3 }]).

Disk vs memory storage

This is the design decision that matters.

// Disk: writes to filesystem, req.file.path points to it
const diskStorage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, "uploads/"),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    cb(null, `${crypto.randomUUID()}${ext}`);
  },
});

// Memory: keeps file in RAM as a Buffer, req.file.buffer
const memoryStorage = multer.memoryStorage();
StorageWhen to useWatch out for
DiskLarge files, eventual persistenceDisk fills up, cleanup is on you
MemoryForwarding to S3, image processing, small filesOOM if files are big or concurrent uploads spike

The classic pattern for S3 uploads is memory storage → pipe the buffer straight to S3, never touch local disk.

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: "us-east-1" });
const upload = multer({ storage: multer.memoryStorage() });

app.post("/upload", upload.single("file"), async (req, res) => {
  await s3.send(new PutObjectCommand({
    Bucket: "my-app-uploads",
    Key: `${crypto.randomUUID()}-${req.file.originalname}`,
    Body: req.file.buffer,
    ContentType: req.file.mimetype,
  }));
  res.json({ ok: true });
});

Size limits and file filters

Always set limits. Always. Without them, someone uploads a 50GB file and your server cries.

const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 MB
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (!allowed.includes(file.mimetype)) {
      return cb(new Error("Only JPEG/PNG/WebP allowed"));
    }
    cb(null, true);
  },
});

When the limit trips, Multer throws a MulterError. Handle it with an error middleware:

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      return res.status(413).json({ error: "File too large (max 5MB)" });
    }
    return res.status(400).json({ error: err.message });
  }
  next(err);
});

Security gotchas

The MIME type from the client is not trustworthy. The Content-Type header is set by the browser based on the file extension — an attacker can rename evil.exe to cat.jpg and the mimetype will say image/jpeg.

For real validation, sniff the actual bytes:

npm install file-type
import { fileTypeFromBuffer } from "file-type";

app.post("/upload", upload.single("file"), async (req, res) => {
  const detected = await fileTypeFromBuffer(req.file.buffer);
  if (!detected || !["jpg", "png", "webp"].includes(detected.ext)) {
    return res.status(400).json({ error: "Not a valid image" });
  }
  // safe to proceed
});

Other rules:

  • Never use originalname as a path — it can contain ../ traversal or weird chars. Generate UUIDs.
  • Strip metadata for privacy (EXIF GPS data in photos). Use sharp to re-encode.
  • Store outside the web root if using disk storage, or files become directly accessible URLs.
  • Scan for malware if you accept arbitrary file types — ClamAV or a service like VirusTotal.

Quick reference

upload.single("avatar")                          // one file
upload.array("photos", 5)                        // up to 5 files, same field
upload.fields([{ name: "avatar" }, { name: "docs" }]) // multiple fields
upload.none()                                    // no files, just form fields
upload.any()                                     // anything — usually avoid

20

Async Error Handling

intermediate express async errors express-5

Express 4 has a famous wart: if an async route handler throws, Express doesn’t catch it. Your error middleware never runs, the request hangs forever, and you eventually get an unhandled rejection in the logs. This trips up every Node dev once.

In simple language: Express 4’s error machinery was written before async/await existed. It only knows how to catch errors thrown synchronously or passed to next(err). A rejected promise from an async function? Invisible to Express.

The bug, visualized

Broken in Express 4
async (req, res) => {
  throw new Error("boom"); // rejects
}

Promise rejection — Express never sees it

Client waits... and waits... and times out
Works (with fix)
async (req, res, next) => {
  try { ... } catch (e) { next(e); }
}

Error middleware runs

Client gets a clean 500

The naive fix: try/catch everywhere

Works, but pollutes every handler.

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.users.findById(req.params.id);
    if (!user) return res.status(404).json({ error: "Not found" });
    res.json(user);
  } catch (err) {
    next(err); // hand off to error middleware
  }
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "Server error" });
});

Repeat for every async route. Forget once → silent hang. Not great.

The wrapper fix

Wrap each async handler in a function that catches and forwards.

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) return res.status(404).json({ error: "Not found" });
  res.json(user);
}));

This is what express-async-handler does. It’s three lines of code — most teams just inline it.

The patch fix: express-async-errors

A monkey-patch that makes Express 4 understand async handlers. Import once at the top of your entry file:

npm install express-async-errors
import "express-async-errors"; // import for side effects, before anything else
import express from "express";

const app = express();

app.get("/users/:id", async (req, res) => {
  const user = await db.users.findById(req.params.id); // can throw freely
  if (!user) throw new Error("Not found");
  res.json(user);
});

app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});

The library monkey-patches Express’s router so promise rejections automatically flow to next(err). It feels magical and it works, but the monkey-patch nature makes some teams squeamish.

Express 5: native support

Express 5 fixes this at the framework level. Async handlers that throw or reject now route to error middleware automatically. No patches, no wrappers.

// Express 5
import express from "express";
const app = express();

app.get("/users/:id", async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) {
    const err = new Error("Not found");
    err.status = 404;
    throw err; // Express 5 catches this
  }
  res.json(user);
});

app.use((err, req, res, next) => {
  res.status(err.status || 500).json({ error: err.message });
});

If you’re starting a new project in 2026, just use Express 5 and skip the workarounds entirely.

The error middleware itself

Whichever fix you pick, the error middleware looks the same. The key signature is four arguments — Express identifies error middleware by arity, not by name.

app.use((err, req, res, next) => {
  // log the real error
  req.log?.error({ err, path: req.path }, "Request failed");

  // map known errors to status codes
  if (err.name === "ValidationError") {
    return res.status(400).json({ error: err.message });
  }
  if (err.name === "UnauthorizedError") {
    return res.status(401).json({ error: "Unauthorized" });
  }

  // fallback — don't leak stack traces in prod
  const msg = process.env.NODE_ENV === "production" ? "Server error" : err.message;
  res.status(err.status || 500).json({ error: msg });
});

Register it last, after all your routes. Express walks middleware in order.

Gotcha: errors in event handlers

The fix only covers errors during request handling. If you setTimeout or setImmediate inside a handler and throw, Express can’t catch it — the call stack has already returned. Wrap those manually.

app.get("/work", (req, res) => {
  res.json({ queued: true });
  setTimeout(() => {
    try {
      doBackgroundWork();
    } catch (err) {
      logger.error({ err }, "Background work failed");
    }
  }, 0);
});

TL;DR

  • Express 4: use express-async-errors (patch) or an asyncHandler wrapper. Pick one and use it everywhere.
  • Express 5: native support, just throw.
  • Always register a 4-arg error middleware as your last app.use.
  • Don’t leak stack traces in production responses.

21

Testing with Supertest

intermediate express testing supertest jest vitest

We don’t need a running server to test Express routes. Supertest hooks straight into the Express app object, fires fake HTTP requests at it, and gives us the response back. Fast, isolated, perfect for CI.

In simple language: Supertest is a robot client that pokes our routes directly in-process. No network, no port, no flakiness from “address already in use”.

The setup

npm install -D supertest vitest    # or jest

The pattern: export your app separately from your listen() call. This is the most important refactor for testability.

// app.js — just the app, no listen
import express from "express";
import { usersRouter } from "./routes/users.js";

export const app = express();
app.use(express.json());
app.use("/users", usersRouter);
// server.js — only this file calls listen
import { app } from "./app.js";
app.listen(3000, () => console.log("listening"));

Now tests can import app and never bind to a port.

A basic test

import request from "supertest";
import { describe, it, expect } from "vitest";
import { app } from "../app.js";

describe("GET /users", () => {
  it("returns a list of users", async () => {
    const res = await request(app).get("/users");

    expect(res.status).toBe(200);
    expect(res.body).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number) }),
      ])
    );
  });

  it("returns 404 for missing user", async () => {
    const res = await request(app).get("/users/99999");
    expect(res.status).toBe(404);
  });
});

request(app) creates a test client. Chain HTTP method + path, send headers/body, await the response. That’s it.

POST with a body

it("creates a user", async () => {
  const res = await request(app)
    .post("/users")
    .send({ email: "manish@example.com", password: "hunter2" })
    .set("Content-Type", "application/json");

  expect(res.status).toBe(201);
  expect(res.body.email).toBe("manish@example.com");
  expect(res.body.password).toBeUndefined(); // shouldn't leak hashes
});

Authenticated routes

Sign a real JWT in the test, or use a test helper that builds a token for a fixture user.

import jwt from "jsonwebtoken";

function authToken(userId, role = "user") {
  return jwt.sign({ sub: userId, role }, process.env.JWT_SECRET, { expiresIn: "5m" });
}

it("returns the current user", async () => {
  const token = authToken("user_42");
  const res = await request(app)
    .get("/me")
    .set("Authorization", `Bearer ${token}`);

  expect(res.status).toBe(200);
  expect(res.body.userId).toBe("user_42");
});

it("rejects requests without a token", async () => {
  const res = await request(app).get("/me");
  expect(res.status).toBe(401);
});

Mocking the database

We don’t want real DB calls in unit tests — slow, flaky, hard to seed. Two common approaches:

1. Inject the DB (dependency injection)

Pass db into route builders so tests can swap it for a stub.

// users.routes.js
export function makeUsersRouter(db) {
  const router = express.Router();
  router.get("/:id", async (req, res) => {
    const user = await db.users.findById(req.params.id);
    user ? res.json(user) : res.sendStatus(404);
  });
  return router;
}
// users.test.js
import { makeUsersRouter } from "../routes/users.routes.js";

const fakeDb = {
  users: {
    findById: vi.fn(async (id) => id === "1" ? { id: "1", name: "Manish" } : null),
  },
};

const app = express();
app.use("/users", makeUsersRouter(fakeDb));

it("finds a user", async () => {
  const res = await request(app).get("/users/1");
  expect(res.body.name).toBe("Manish");
  expect(fakeDb.users.findById).toHaveBeenCalledWith("1");
});

This is the cleanest pattern. The app code doesn’t import a singleton DB — it accepts one.

2. Module mocking

If refactoring is too painful, mock at the module level.

import { vi } from "vitest";

vi.mock("../db.js", () => ({
  db: {
    users: {
      findById: vi.fn(),
    },
  },
}));

import { db } from "../db.js";

it("finds a user", async () => {
  db.users.findById.mockResolvedValue({ id: "1", name: "Manish" });
  const res = await request(app).get("/users/1");
  expect(res.body.name).toBe("Manish");
});

Works, but tests get coupled to import paths. DI scales better.

Integration tests with a real DB

For higher-confidence tests, spin up a real Postgres in Docker (or use Testcontainers), seed it, and let the app hit it. Slower but catches things mocks never will — like a forgotten index or a SQL typo.

import { beforeAll, afterAll, beforeEach } from "vitest";
import { pool } from "../db.js";

beforeAll(async () => {
  await pool.query("CREATE TABLE IF NOT EXISTS users (...)");
});

beforeEach(async () => {
  await pool.query("TRUNCATE users RESTART IDENTITY");
});

afterAll(async () => {
  await pool.end();
});

it("persists a user", async () => {
  await request(app).post("/users").send({ email: "a@b.com", password: "xxxxxxxx" });
  const { rows } = await pool.query("SELECT * FROM users");
  expect(rows).toHaveLength(1);
});

Useful Supertest tricks

// Cookies persist across requests using an agent
const agent = request.agent(app);
await agent.post("/login").send({ email, password });
const me = await agent.get("/me"); // session cookie auto-sent

// File upload
await request(app)
  .post("/upload")
  .attach("avatar", "test/fixtures/cat.jpg")
  .field("caption", "vacation");

// Assert response headers
const res = await request(app).get("/users");
expect(res.headers["content-type"]).toMatch(/json/);
expect(res.headers["x-ratelimit-limit"]).toBeDefined();

What to test

Layered approach, from cheap to expensive:

  1. Unit tests for pure business logic (validators, helpers). Fastest.
  2. Route tests with mocked DB (Supertest + DI). Cover handler logic + middleware wiring.
  3. Integration tests against a real DB. Cover SQL, migrations, real wiring.
  4. E2E tests against a deployed instance. Slowest, run before release.

Most teams over-invest in level 4 and skip level 2. Route tests with Supertest are the sweet spot — fast enough to run on every save, thorough enough to catch real bugs.