Skip to main content

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.

FrameworkPhilosophyUse when
ExpressMinimal, DIYLearning fundamentals, small APIs, maximum control
FastifyPerformance-first, schema-drivenHigh-throughput APIs, JSON-heavy workloads
KoaMinimal, context-based APIYou want a smaller core than Express
NestJSOpinionated, Angular-inspired, Express or FastifyLarge 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"
}
}
info

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
Common mistake

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, populates req.body
  • express.urlencoded() — parses URL-encoded form bodies (Express 5 defaults to extended: false; pass { extended: true } if you need nested objects in form data)
  • express.static("public") — serves static files from a directory
  • express.raw() — parses body as a Buffer (for webhooks, binary data)
  • express.text() — parses body as a plain string
Common mistake

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:

SourcePropertyExample
URL parameter /tasks/:idreq.params.id"abc-123"
Query string ?status=donereq.query.status"done"
JSON bodyreq.body{ title: "New task" }
Headersreq.headers.authorization"Bearer eyJ..."

Response — HTTP status codes you will use:

CodeNameWhen
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST (resource created)
204No ContentSuccessful DELETE (nothing to return)
400Bad RequestValidation failed, malformed input
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not allowed
404Not FoundResource does not exist
500Internal Server ErrorUnhandled 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.

Common mistake

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.

warning

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.

info

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.

Frontend comparison

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 the Authorization: Bearer header 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:

  1. Browser sends a preflight OPTIONS request before the actual request (for non-simple methods like PUT/DELETE or custom headers like Authorization)
  2. Server responds with Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers
  3. Browser checks these headers — if the origin and method are allowed, it sends the real request
  4. The cors middleware 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 }));
Common mistake

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:

  1. 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). Calling next() passes control to the next middleware in the chain. If you forget to call next() 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 read req.body, and the error handler must come last.

  2. 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 .length property to detect this. If you omit next from an error handler (even if unused), Express treats it as regular middleware and errors will not reach it.

  3. 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/catch and call next(err) manually. This means Express 5 async handlers are much safer by default.

  4. Why should you separate app.ts from index.ts in an Express project? — Testability. Testing libraries like supertest need to import the Express app object to make HTTP requests against it without starting a real server. If app.ts calls app.listen(), importing it for tests also starts the server, causing port conflicts and side effects. With the split, tests import app.ts directly and supertest handles the HTTP layer.

  5. 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 OK is for successful requests that return existing data (GET, PUT, PATCH). Using 201 is 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.

  6. What happens during a CORS preflight request, and when does the browser send one? — The browser sends an OPTIONS request before the actual request when it is "non-simple": using methods other than GET/POST/HEAD, or custom headers like Authorization, or Content-Type: application/json. The server responds with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. The browser checks these headers — if the origin, method, and headers are all allowed, it sends the real request. The cors middleware handles this automatically.

  7. Why does setting origin: '*' not work when credentials: 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 that credentials: true specifically controls cookies and HTTP auth credentials — bearer tokens in the Authorization header are controlled by allowedHeaders, which is a related but separate concern.