Express.js
Why Express
Express is the minimal, unopinionated HTTP framework for Node.js. It gives you routing and a middleware pipeline — you bring everything else (database, auth, validation). Many Node.js frameworks build on its concepts: NestJS uses Express as its default HTTP adapter (with Fastify as an alternative), while others like Koa share the same middleware philosophy.
| Framework | Philosophy | Use when |
|---|---|---|
| Express | Minimal, DIY | Learning fundamentals, small APIs, maximum control |
| Fastify | Performance-first, schema-driven | High-throughput APIs, JSON-heavy workloads |
| Koa | Minimal, context-based API | You want a smaller core than Express |
| NestJS | Opinionated, Angular-inspired, Express or Fastify | Large enterprise apps, teams, decorator-based DI |
We use Express 5 (current stable, requires Node.js 20+). Express 5 added native async/await error handling — rejected promises in route handlers are automatically forwarded to the error handler. NestJS adds structure on top.
Project Setup
Initialize
mkdir task-api && cd task-api
npm init -y
Install runtime dependencies:
npm install express cors dotenv
Install dev/type dependencies:
npm install -D typescript tsx @types/node @types/express @types/cors
Update package.json — note "type": "module" for ESM support (lecture 65):
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
tsx uses esbuild under the hood — it transpiles TypeScript instantly without type-checking. For type-checking, run tsc --noEmit separately. This is the same approach Vite uses for the frontend.
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
Folder structure
What we build in this lecture:
task-api/
├── public/
│ └── index.html
├── src/
│ ├── index.ts
│ ├── app.ts
│ ├── middleware/
│ │ └── errorHandler.ts
│ └── routes/
│ └── tasks.ts
├── .env
├── package.json
└── tsconfig.json
The next lecture adds database, auth, categories, testing, and Docker.
Minimal server
Create two files — app.ts creates the Express application, index.ts starts it:
// src/app.ts
import express from "express";
const app = express();
app.use(express.json({ limit: "1mb" }));
export default app;
The limit option caps the maximum request body size. The default is 100kb. Set it to what your app actually needs — accepting unbounded bodies is a denial-of-service vector.
// src/index.ts
import "dotenv/config"; // side-effect import — loads .env into process.env
import app from "./app.js";
const PORT = process.env.PORT || 3000;
app.listen(PORT, (error?: Error) => {
if (error) throw error;
console.log(`Server running on http://localhost:${PORT}`);
});
import "dotenv/config" is a side-effect import — it runs dotenv.config() during module evaluation. In ESM, this must come before other imports that read process.env, because all import declarations are hoisted and evaluated before the module body runs. Writing dotenv.config() in the body would be too late.
Start the dev server:
npm run dev
Never put app.listen() in app.ts. Separating the app creation from the server start is required for testing — supertest imports the app and makes requests without binding to a port. If app.ts calls .listen(), importing it for tests starts a real server.
Routing & Middleware
Middleware pipeline
Every request flows through a chain of middleware functions. Each function receives (req, res, next) and either sends a response or calls next() to pass control to the next middleware.
Request → logger → express.json() → cors() → router → errorHandler → Response
// src/app.ts — add a logging middleware
import express from "express";
const app = express();
// Logging middleware — runs for every request
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
console.log(`${req.method} ${req.url} ${res.statusCode} ${ms}ms`);
});
next();
});
app.use(express.json());
export default app;
Order matters. If you put the logger after the route handler, it never runs for requests that the handler already responded to.
Built-in middleware
Express ships with built-in middleware for common tasks:
express.json()— parses JSON request bodies, populatesreq.bodyexpress.urlencoded()— parses URL-encoded form bodies (Express 5 defaults toextended: false; pass{ extended: true }if you need nested objects in form data)express.static("public")— serves static files from a directoryexpress.raw()— parses body as aBuffer(for webhooks, binary data)express.text()— parses body as a plain string
Forgetting app.use(express.json()) means req.body is always undefined. This is the #1 issue novices hit when testing POST endpoints. No error is thrown — the body is simply missing.
Router
express.Router() creates a modular, mountable route handler. Define routes on a router, then mount it on a path:
// src/routes/tasks.ts
import { Router, Request, Response } from "express";
const router = Router();
const tasks = [
{ id: "1", title: "Learn Express", status: "in_progress" },
{ id: "2", title: "Build API", status: "todo" },
];
router.get("/", (req: Request, res: Response) => {
res.json(tasks);
});
router.get("/:id", (req: Request, res: Response) => {
const task = tasks.find((t) => t.id === req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
res.json(task);
});
export default router;
Mount it in app.ts:
// src/app.ts
import express from "express";
import tasksRouter from "./routes/tasks.js";
const app = express();
app.use(express.json());
app.use("/api/v1/tasks", tasksRouter);
export default app;
Paths compose: the router's / + the mount path /api/v1/tasks = GET /api/v1/tasks. The router's /:id becomes GET /api/v1/tasks/:id.
Request & Response
Request object — where data comes from:
| Source | Property | Example |
|---|---|---|
URL parameter /tasks/:id | req.params.id | "abc-123" |
Query string ?status=done | req.query.status | "done" |
| JSON body | req.body | { title: "New task" } |
| Headers | req.headers.authorization | "Bearer eyJ..." |
Response — HTTP status codes you will use:
| Code | Name | When |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE (nothing to return) |
| 400 | Bad Request | Validation failed, malformed input |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not allowed |
| 404 | Not Found | Resource does not exist |
| 500 | Internal Server Error | Unhandled server error |
// Response examples
res.json(tasks); // 200 (default)
res.status(201).json(newTask); // 201 Created
res.status(204).send(); // 204 No Content
res.status(400).json({ errors: ["Title required"] }); // 400 Bad Request
res.status(404).json({ error: "Not found" }); // 404 Not Found
Error handling middleware
Express identifies error-handling middleware by its 4-parameter signature: (err, req, res, next). It must be registered after all routes.
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from "express";
export class AppError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
): void {
// If headers were already sent, delegate to Express default handler
if (res.headersSent) {
next(err);
return;
}
if (err instanceof AppError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
console.error(`Unhandled error: ${err.message}`);
res.status(500).json({ error: "Internal server error" });
}
Use AppError in routes to throw specific HTTP errors:
import { AppError } from "../middleware/errorHandler.js";
// In a route handler:
throw new AppError(404, "Task not found");
throw new AppError(409, "Email already registered");
Wire it into app.ts as the last middleware:
// src/app.ts
import express from "express";
import tasksRouter from "./routes/tasks.js";
import { errorHandler } from "./middleware/errorHandler.js";
const app = express();
app.use(express.json());
app.use("/api/v1/tasks", tasksRouter);
// Error handler must be last
app.use(errorHandler);
export default app;
In Express 5, async route handlers automatically forward errors to the error handler. If an async function throws or a promise rejects, Express catches it — no try/catch or next(err) needed:
// Express 5 — thrown errors are automatically caught
router.get("/:id", async (req: Request, res: Response) => {
const task = await findTaskOrThrow(req.params.id); // if this throws, error handler runs
res.json(task);
});
This also works with synchronous throw:
router.get("/:id", (req: Request, res: Response) => {
throw new AppError(404, "Task not found"); // caught automatically
});
You can still call next(error) explicitly when you want to pass errors from non-throwing code, but the automatic forwarding covers most cases.
Error handlers MUST have exactly 4 parameters. If you omit next, Express treats it as regular middleware (3 params) and it will never catch errors. Even if you don't use next, you must declare it.
Serving Static Files
Express can serve static files directly — useful during development when your frontend and backend run from the same origin (no CORS issues).
// src/app.ts — add before routes
app.use(express.static("public"));
Create a simple test page:
<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head><title>Task API Test</title></head>
<body>
<h1>Tasks</h1>
<ul id="tasks"></ul>
<script>
fetch("/api/v1/tasks")
.then((res) => res.json())
.then((tasks) => {
const ul = document.getElementById("tasks");
tasks.forEach((t) => {
const li = document.createElement("li");
li.textContent = `${t.title} [${t.status}]`;
ul.appendChild(li);
});
});
</script>
</body>
</html>
Open http://localhost:3000 — the HTML loads from Express, fetches from /api/v1/tasks on the same origin.
This test page works while routes are unprotected. After adding authentication (next lecture), /api/v1/tasks returns 401 without a Bearer token. At that point, use your Vue/React frontend or a tool like Postman/curl to test authenticated endpoints.
In production, nginx or your CDN serves static files. Your Vue/React frontend is a separate build and deployment. express.static is for development convenience and simple server-rendered pages.
CORS
You already know what CORS is from the client side — the browser blocks responses from a different origin unless the server explicitly allows it. Now we configure the server.
In Vue (lecture 53) you used Vite's server.proxy to bypass CORS during development. That proxies requests through Vite's dev server to the API. Now you configure the actual CORS headers on the Express server — no proxy needed.
// src/app.ts — add CORS
import cors from "cors";
app.use(
cors({
origin: ["http://localhost:5173", "http://localhost:3001"], // Vite / Next.js dev servers
credentials: true, // allow cookies in cross-origin requests (if you use them later)
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
})
);
Two separate concerns here:
allowedHeaders: ["Authorization"]— lets the browser send theAuthorization: Bearerheader cross-origin. This is what your JWT-based frontend needs.credentials: true— tells the browser it is allowed to send cookies and HTTP authentication credentials cross-origin. Not strictly needed for bearer-token auth, but commonly included to support cookie-based sessions if added later.
How CORS works on the server:
- Browser sends a preflight
OPTIONSrequest before the actual request (for non-simple methods like PUT/DELETE or custom headers like Authorization) - Server responds with
Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers - Browser checks these headers — if the origin and method are allowed, it sends the real request
- The
corsmiddleware handles all of this automatically
For production, replace the localhost origins with your actual domain:
const allowedOrigins = process.env.CORS_ORIGIN?.split(",") || ["http://localhost:5173"];
app.use(cors({ origin: allowedOrigins, credentials: true }));
origin: '*' does not work with credentials: true. Browsers reject wildcard origins when credentials (cookies, Authorization headers) are included. You must specify the exact origin(s). This causes a confusing CORS error that looks like the server is misconfigured when really it is just the wildcard + credentials combination.
Defence Preparation
Be prepared to explain topics like these:
-
What is middleware in Express, and what is the significance of calling
next()? — Middleware functions sit in a pipeline and process the request sequentially. Each function receives(req, res, next). Callingnext()passes control to the next middleware in the chain. If you forget to callnext()and don't send a response, the request hangs indefinitely — the client waits forever. Middleware order matters:express.json()must come before route handlers that readreq.body, and the error handler must come last. -
How does Express distinguish an error-handling middleware from a regular middleware? — By the number of parameters. Regular middleware has 3 parameters
(req, res, next). Error-handling middleware has 4 parameters(err, req, res, next). Express uses the function's.lengthproperty to detect this. If you omitnextfrom an error handler (even if unused), Express treats it as regular middleware and errors will not reach it. -
How does Express 5 handle errors in async route handlers differently from Express 4? — In Express 5, if an async route handler throws an error or returns a rejected promise, Express automatically forwards it to the error-handling middleware. In Express 4, unhandled promise rejections were silently lost — you had to wrap every async handler in
try/catchand callnext(err)manually. This means Express 5 async handlers are much safer by default. -
Why should you separate
app.tsfromindex.tsin an Express project? — Testability. Testing libraries likesupertestneed to import the Expressappobject to make HTTP requests against it without starting a real server. Ifapp.tscallsapp.listen(), importing it for tests also starts the server, causing port conflicts and side effects. With the split, tests importapp.tsdirectly andsupertesthandles the HTTP layer. -
What HTTP status code should a successful POST (resource creation) return, and why not 200? —
201 Created. This tells the client that a new resource was created, not just that the request succeeded.200 OKis for successful requests that return existing data (GET, PUT, PATCH). Using201is semantically correct and allows clients to handle creation differently (e.g., redirect to the new resource). The response should include the created resource in the body. -
What happens during a CORS preflight request, and when does the browser send one? — The browser sends an
OPTIONSrequest before the actual request when it is "non-simple": using methods other than GET/POST/HEAD, or custom headers likeAuthorization, orContent-Type: application/json. The server responds withAccess-Control-Allow-Origin,Access-Control-Allow-Methods, andAccess-Control-Allow-Headers. The browser checks these headers — if the origin, method, and headers are all allowed, it sends the real request. Thecorsmiddleware handles this automatically. -
Why does setting
origin: '*'not work whencredentials: true? — This is a browser security restriction (the Fetch specification). When credentialed cross-origin requests are enabled (cookies, HTTP authentication), the server must respond with a specific origin, not a wildcard. The reason: a wildcard would allow any site to make authenticated requests using the user's cookies. Note thatcredentials: truespecifically controls cookies and HTTP auth credentials — bearer tokens in theAuthorizationheader are controlled byallowedHeaders, which is a related but separate concern.