Skip to content

2. REST APIs with Express


1. What Is Express?

A minimal, unopinionated web framework for Node. It gives you routing, middleware and helpers on top of the built-in http module — and almost nothing else, which is the point.

Node HTTP vs Express

js
// Raw Node
import http from "node:http";

http.createServer((req, res) => {
  if (req.url === "/users" && req.method === "GET") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(users));
  }
}).listen(3000);

// Express
app.get("/users", (req, res) => res.json(users));

Express handles routing, body parsing, param extraction and the middleware chain. You would rebuild all of it by hand otherwise.


2. Basic Setup

js
import express from "express";
import helmet from "helmet";
import cors from "cors";

const app = express();

app.use(helmet());                              // security headers
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(express.json({ limit: "10kb" }));       // parse JSON, cap the size
app.use(express.urlencoded({ extended: true }));

app.use("/api/users", userRoutes);
app.use("/api/orders", orderRoutes);

app.use(notFoundHandler);   // 404 — after all routes
app.use(errorHandler);      // error handler — always LAST

app.listen(process.env.PORT ?? 3000);

Order Matters

Middleware runs top to bottom. The 404 handler must come after all routes; the error handler must be last. Getting this wrong is the most common Express bug.

The limit: "10kb" on the body parser is a small DoS protection — without it a client can post a 500 MB JSON body and exhaust your memory.


3. REST Principles

PrincipleMeaning
Resource-based URLs/users/1, not /getUser?id=1
HTTP verbs carry the actionGET, POST, PUT, PATCH, DELETE
StatelessEvery request carries everything the server needs
Correct status codesNot 200 { success: false }
Nouns, plural/users, not /user or /getUsers

URL Design

GET    /api/users            list
GET    /api/users/:id        one
POST   /api/users            create
PUT    /api/users/:id        full replace
PATCH  /api/users/:id        partial update
DELETE /api/users/:id        delete

GET    /api/users/:id/orders   nested resource
GET    /api/users?role=admin&page=2&sort=-createdAt   filter, paginate, sort

PUT vs PATCH

PUT replaces the whole resource — omitted fields are cleared. PATCH updates only the fields you send. PUT is idempotent by definition; PATCH usually is too, but is not required to be.

Status Codes

CodeWhen
200OK
201Created — include a Location header
204No content — successful DELETE
400Bad request / validation failed
401Not authenticated ("who are you?")
403Authenticated but not allowed ("I know you, no")
404Not found
409Conflict — duplicate email, version mismatch
422Unprocessable — semantically invalid
429Too many requests
500Server error
503Service unavailable

401 vs 403 is asked constantly. 401 = missing or invalid credentials. 403 = valid credentials, insufficient permission.


4. Routing

js
// routes/users.route.js
import { Router } from "express";

const router = Router();

router.get("/", listUsers);
router.get("/:id", getUser);
router.post("/", validate(createUserSchema), createUser);
router.patch("/:id", requireAuth, updateUser);
router.delete("/:id", requireAuth, requireRole("admin"), deleteUser);

export default router;
js
app.use("/api/users", router);   // mounted — routes become /api/users/*

Route Parameters

js
req.params.id       // /users/:id
req.query.page      // ?page=2 — always a string
req.body            // parsed JSON body
req.headers         // lowercase keys

Everything in params and query is a string. req.query.page is "2", not 2. Coerce and validate.

Route Order

js
app.get("/users/new", …);   // must come FIRST
app.get("/users/:id", …);   // otherwise "new" matches :id

Express matches in declaration order. Static segments before dynamic ones.

Express 5 Path Syntax Change

Express 5 upgraded path-to-regexp. Wildcards changed:

js
app.get("/files/*", …);          // Express 4
app.get("/files/*splat", …);     // Express 5 — named wildcard

Unnamed * and bare regex-style paths were removed. A real migration gotcha.


5. Controller Pattern

Keep routes thin. Keep business logic out of Express.

js
// controllers/users.controller.js
export async function getUser(req, res, next) {
  try {
    const user = await userService.findById(req.params.id);
    if (!user) throw new NotFoundError("User");
    res.json(user);
  } catch (err) {
    next(err);
  }
}
js
// services/users.service.js — NO Express types, no req/res
export async function findById(id) {
  return db.user.findUnique({
    where: { id },
    select: { id: true, name: true, email: true },   // never select passwordHash
  });
}

Why This Matters

The service can be called from a route, a cron job, a queue worker or a test — no fake req/res needed. Interviewers actively look for this separation.

Note the explicit select. Returning the whole row leaks passwordHash the moment someone forgets to strip it. Allowlist the fields.


6. Pagination

Offset Pagination

js
export async function listUsers(req, res) {
  const page = Math.max(1, Number(req.query.page) || 1);
  const limit = Math.min(100, Number(req.query.limit) || 20);   // cap it
  const skip = (page - 1) * limit;

  const [items, total] = await Promise.all([
    db.user.findMany({ skip, take: limit, orderBy: { createdAt: "desc" } }),
    db.user.count(),
  ]);

  res.json({
    data: items,
    pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
  });
}

Cap the limit. Without it, ?limit=1000000 is a free denial of service.

Cursor Pagination

js
const items = await db.user.findMany({
  take: limit,
  ...(cursor && { skip: 1, cursor: { id: cursor } }),
  orderBy: { id: "asc" },
});

res.json({ data: items, nextCursor: items.at(-1)?.id ?? null });

Which and Why

OffsetCursor
Jump to page NYesNo
Total countEasyExpensive
Performance at page 10,000Terrible — the DB scans and discardsConstant
Items shifting during pagingDuplicates and skipsStable

Offset for admin tables with page numbers. Cursor for infinite scroll and large datasets. Naming the "page 10,000 scans 200,000 rows" problem is the answer that lands.


7. Validation

Never trust the client.

js
import { z } from "zod";

const createUserSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  password: z.string().min(8),
  role: z.enum(["user", "admin"]).default("user"),
});

export function validate(schema) {
  return (req, res, next) => {
    const parsed = schema.safeParse(req.body);
    if (!parsed.success) {
      return res.status(400).json({
        error: "Validation failed",
        issues: parsed.error.flatten().fieldErrors,
      });
    }
    req.body = parsed.data;   // coerced and stripped of unknown keys
    next();
  };
}

router.post("/", validate(createUserSchema), createUser);

Zod strips unknown keys by default, which also blocks mass assignment — a client sending { role: "admin" } to a signup endpoint.


8. Response Shape

Pick one shape and be consistent.

js
// Success
{ "data": { … } }
{ "data": [ … ], "pagination": { … } }

// Error
{ "error": { "code": "VALIDATION_FAILED", "message": "…", "issues": { … } } }

A machine-readable code matters more than a pretty message — clients should branch on the code, not parse English.


9. Security Middleware

js
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import cors from "cors";

app.use(helmet());   // sets ~12 security headers

app.use(cors({
  origin: ["https://app.example.com"],   // never "*" with credentials
  credentials: true,
}));

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  message: { error: "Too many attempts, try again later" },
});

app.use("/api/auth/login", authLimiter);

The Checklist

AttackDefence
XSSEscape output; helmet CSP; never trust stored HTML
SQL injectionParameterised queries / an ORM — never string concatenation
NoSQL injectionValidate types; { $gt: "" } as a password is a real exploit
CSRFsameSite cookies, or CSRF tokens
Brute forceRate limiting on auth endpoints
DoS via payloadexpress.json({ limit })
Mass assignmentValidate and allowlist fields
Secrets in git.env gitignored, secrets from the platform
Dependency CVEsnpm audit, Dependabot
Info disclosureGeneric 500 messages; app.disable("x-powered-by")

The NoSQL Injection Example

js
// ❌ Client sends { "email": "a@b.com", "password": { "$gt": "" } }
const user = await User.findOne({ email: req.body.email, password: req.body.password });
// MongoDB matches any password

Validating that password is a string kills it. Worth mentioning unprompted — it shows you think about attacker input, not just happy paths.


10. Logging

js
import pino from "pino";
import pinoHttp from "pino-http";
import { randomUUID } from "node:crypto";

const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  redact: ["req.headers.authorization", "req.body.password", "*.creditCard"],
});

app.use(pinoHttp({
  logger,
  genReqId: (req) => req.headers["x-request-id"] ?? randomUUID(),
}));

Three Things Interviewers Check

  1. Structured JSON logs, not console.log — you cannot query free text
  2. Redaction of passwords, tokens and PII — logs get shipped to third parties
  3. A request ID propagated through every log line, so you can trace one request across services

11. Health Checks

js
app.get("/health", (req, res) => res.json({ status: "ok" }));   // liveness

app.get("/ready", async (req, res) => {                          // readiness
  try {
    await db.$queryRaw`SELECT 1`;
    await redis.ping();
    res.json({ status: "ready" });
  } catch {
    res.status(503).json({ status: "not ready" });
  }
});

Liveness = "is the process alive" (restart me if not). Readiness = "can I serve traffic" (stop routing to me if not). Load balancers and Kubernetes need both, and confusing them causes restart loops.


12. Express 4 vs Express 5

Express 4Express 5
Async errorsNot caught — request hangsForwarded to the error handler
path-to-regexpv0.xv8 — named wildcards required
req.queryObjectGetter, no longer mutable in place
res.send(status)Deprecated overloadRemoved
app.del()PresentRemoved — use app.delete()
Node minimum0.10+18+

The One That Matters

In Express 4, an async handler that throws leaves the request hanging forever — no response, no error, just a client timeout. That's why express-async-errors and asyncHandler wrappers exist.

js
// Express 4 fix
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users", asyncHandler(async (req, res) => {
  res.json(await getUsers());
}));

Express 5 makes this unnecessary. Knowing which version you're on, and why the wrapper exists, is a good interview answer.

© 2025 DDocs · Dipak's Documentation Guide