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
// 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
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
| Principle | Meaning |
|---|---|
| Resource-based URLs | /users/1, not /getUser?id=1 |
| HTTP verbs carry the action | GET, POST, PUT, PATCH, DELETE |
| Stateless | Every request carries everything the server needs |
| Correct status codes | Not 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, sortPUT 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
| Code | When |
|---|---|
| 200 | OK |
| 201 | Created — include a Location header |
| 204 | No content — successful DELETE |
| 400 | Bad request / validation failed |
| 401 | Not authenticated ("who are you?") |
| 403 | Authenticated but not allowed ("I know you, no") |
| 404 | Not found |
| 409 | Conflict — duplicate email, version mismatch |
| 422 | Unprocessable — semantically invalid |
| 429 | Too many requests |
| 500 | Server error |
| 503 | Service unavailable |
401 vs 403 is asked constantly. 401 = missing or invalid credentials. 403 = valid credentials, insufficient permission.
4. Routing
// 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;app.use("/api/users", router); // mounted — routes become /api/users/*Route Parameters
req.params.id // /users/:id
req.query.page // ?page=2 — always a string
req.body // parsed JSON body
req.headers // lowercase keysEverything in params and query is a string. req.query.page is "2", not 2. Coerce and validate.
Route Order
app.get("/users/new", …); // must come FIRST
app.get("/users/:id", …); // otherwise "new" matches :idExpress matches in declaration order. Static segments before dynamic ones.
Express 5 Path Syntax Change
Express 5 upgraded path-to-regexp. Wildcards changed:
app.get("/files/*", …); // Express 4
app.get("/files/*splat", …); // Express 5 — named wildcardUnnamed * and bare regex-style paths were removed. A real migration gotcha.
5. Controller Pattern
Keep routes thin. Keep business logic out of Express.
// 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);
}
}// 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
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
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
| Offset | Cursor | |
|---|---|---|
| Jump to page N | Yes | No |
| Total count | Easy | Expensive |
| Performance at page 10,000 | Terrible — the DB scans and discards | Constant |
| Items shifting during paging | Duplicates and skips | Stable |
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.
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.
// 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
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
| Attack | Defence |
|---|---|
| XSS | Escape output; helmet CSP; never trust stored HTML |
| SQL injection | Parameterised queries / an ORM — never string concatenation |
| NoSQL injection | Validate types; { $gt: "" } as a password is a real exploit |
| CSRF | sameSite cookies, or CSRF tokens |
| Brute force | Rate limiting on auth endpoints |
| DoS via payload | express.json({ limit }) |
| Mass assignment | Validate and allowlist fields |
| Secrets in git | .env gitignored, secrets from the platform |
| Dependency CVEs | npm audit, Dependabot |
| Info disclosure | Generic 500 messages; app.disable("x-powered-by") |
The NoSQL Injection Example
// ❌ Client sends { "email": "a@b.com", "password": { "$gt": "" } }
const user = await User.findOne({ email: req.body.email, password: req.body.password });
// MongoDB matches any passwordValidating that password is a string kills it. Worth mentioning unprompted — it shows you think about attacker input, not just happy paths.
10. Logging
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
- Structured JSON logs, not
console.log— you cannot query free text - Redaction of passwords, tokens and PII — logs get shipped to third parties
- A request ID propagated through every log line, so you can trace one request across services
11. Health Checks
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 4 | Express 5 | |
|---|---|---|
| Async errors | Not caught — request hangs | Forwarded to the error handler |
path-to-regexp | v0.x | v8 — named wildcards required |
req.query | Object | Getter, no longer mutable in place |
res.send(status) | Deprecated overload | Removed |
app.del() | Present | Removed — use app.delete() |
| Node minimum | 0.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.
// 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.