What this module covers: Express is the most widely used Node.js web framework — and for good reason. It takes the built-in http module and adds routing, middleware, and a clean request/response API without imposing a rigid structure. This module covers everything you need to build a real REST API: routing, route parameters, query strings, request bodies, middleware pipelines, and error responses. By the end you will have a working multi-route API with proper HTTP semantics.
This works, but it has no routing. Every request — regardless of URL or method — hits the same handler. To build a real API you need to route GET /users to one handler, POST /users to another, DELETE /users/:id to a third. You also need to parse JSON bodies, read headers, set response codes properly, and handle errors consistently.
You could implement all of that yourself on top of http. Express already did it — and did it well.
npm install express
Version note: you're likely installing Express 5.npm install express has pulled Express 5.x since September 2024 — that's the latest tag now, not Express 4. This module's code works the same under both versions, but two differences are worth flagging up front so the rest of the module doesn't silently assume Express 4 behavior:
Async error forwarding. In Express 5, if an async route handler's promise rejects, Express automatically forwards that rejection to your error-handling middleware. You don't strictly need the manual try { ... } catch (err) { next(err) } pattern shown in the Error Handling section below — though it's still fine practice, and it is required if you're on Express 4 (still extremely common in existing production codebases). The Error Handling section explains why that pattern exists in the first place.
Stricter routing. Express 5 ships a new router (path-to-regexp v8) that rejects some Express 4-era path patterns — most notably bare wildcards like app.get('*', ...), which now throw at startup. Use a named wildcard like app.get('/*splat', ...) instead.
Run npm ls express if you're ever unsure which version a project is actually running.
Route parameters are named placeholders in the URL path, prefixed with :. Express captures them and puts them in req.params.
// :id is a route parameter app.get('/users/:id', (req, res) => { const { id } = req.params; // e.g. '42' (always a string) res.json({ userId: id }); });
For POST, PUT, and PATCH requests, data comes in the request body. With express.json() middleware registered, Express automatically parses JSON bodies into req.body.
// Middleware registered once at the top — applies to all routes app.use(express.json());
// POST /users — create a new user app.post('/users', async (req, res) => { const { name, email, role } = req.body;
// Basic validation if (!name || !email) { return res.status(400).json({ error: 'name and email are required' }); }
const newUser = await db.createUser({ name, email, role: role || 'user' }); res.status(201).json(newUser); // 201 Created });
Middleware is the backbone of Express. A middleware function receives (req, res, next) and either:
Responds to the request (ends the chain), or
Calls next() to pass control to the next middleware or route handler
Think of it as an airport security line — each function is a checkpoint that either pulls you aside and sends you home, or waves you through to the next checkpoint.
// Middleware executes in the order it's registered app.use(express.json()); // 1. Parse JSON body app.use(requestLogger); // 2. Log the request app.use(authenticate); // 3. Verify auth token app.get('/users', listUsers); // 4. Handle the route
// Simple API key check function requireApiKey(req, res, next) { const apiKey = req.headers['x-api-key'];
if (!apiKey || apiKey !== process.env.API_KEY) { // Respond here — do NOT call next() return res.status(401).json({ error: 'Invalid or missing API key' }); }
next(); // Only called if the key is valid }
// Apply to specific routes only app.get('/admin/stats', requireApiKey, (req, res) => { res.json({ totalUsers: 1500 }); });
Express has a special error-handling middleware signature: four arguments (err, req, res, next). Register it after all routes.
Why the try/catch below is necessary (on Express 4): for a synchronous route handler, Express catches a thrown error automatically. But an async handler doesn't throw synchronously — it returns a Promise, and that promise rejects. Express 4's routing layer never attaches a .catch() to the promise your handler returns, so if that rejection isn't caught somewhere, Express 4 never learns the request failed. It doesn't call next(err), it doesn't send a 500 — the rejection just becomes an unhandled promise rejection, and the client is left hanging until something times out. The catch (err) { next(err) } block below is you doing, by hand, the promise handling Express 4 doesn't do for you. As the version note near npm install express mentioned, Express 5 closes this gap by forwarding rejected promises to your error middleware automatically — but the pattern below still works on both versions and is worth knowing, especially if you ever touch an Express 4 codebase.
// Route that might throw app.get('/users/:id', async (req, res, next) => { try { const user = await db.findById(req.params.id); res.json(user); } catch (err) { next(err); // Forward error to the error handler } });
// Global error handler — must be last app.use() app.use((err, req, res, next) => { console.error(err.stack);
// Don't expose internal errors in production const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message;
// Set response headers res.setHeader('X-Custom-Header', 'value'); res.setHeader('Cache-Control', 'no-store');
res.json({ message: 'OK' }); });
Production story: a payments API deployed behind an AWS ALB never configured app.set('trust proxy', ...). req.ip read the load balancer's internal IP on every single request, because that's the address Express saw at the raw socket level — the ALB terminates the client connection and opens its own to your app. The team's IP-based fraud-velocity throttling keyed off req.ip, so every request looked like it came from one IP: the ALB. The throttle silently never fired for weeks, because from Express's point of view there was no way to distinguish 40,000 different attackers from one very chatty load balancer. It surfaced only when a manual audit noticed "the same IP" had made 40,000 requests in an hour. The fix was the one-line trust proxy setting covered below — it tells Express to read the real client IP out of X-Forwarded-For instead.
The examples so far are enough to build a working API, but a handful of additional middleware are standard practice before anything reaches production traffic.
If your API is called from a browser-based frontend on a different origin (https://app.example.com calling https://api.example.com), the browser blocks the response unless the server sends CORS headers. The cors package handles this:
npm install cors
import cors from 'cors';
// Allow a specific frontend origin app.use(cors({ origin: 'https://app.example.com', credentials: true, // allow cookies/auth headers }));
Without this, fetch() calls from your frontend fail with a CORS error in the browser console even though the server responded fine — the browser enforces this, not Express.
helmet sets a collection of HTTP response headers that guard against common attacks (clickjacking, MIME-sniffing, and others). It's a one-line addition and a standard baseline for any production Express app:
If your app runs behind a load balancer, reverse proxy, or CDN — which almost every production deployment does — req.ip reports the proxy's address, not the client's, unless you explicitly tell Express to trust the proxy:
app.set('trust proxy', 1); // trust the first hop (e.g. one ALB/nginx in front of the app)
This tells Express to read the client's real IP from the X-Forwarded-For header set by the proxy, instead of the raw socket address. It affects req.ip, req.ips, and anything downstream that keys off IP — rate limiters, fraud checks, geolocation. Skipping this is exactly what broke the payments API in the story above.
express.json() accepts a limit option. Without one, a client can send an enormous JSON body and tie up server memory and CPU parsing it — a basic denial-of-service vector:
app.use(express.json({ limit: '1mb' })); // reject bodies larger than 1mb
Pick a limit based on what your API legitimately needs — a user-profile update needs far less headroom than a bulk-import endpoint.
Here is a minimal in-memory users API that uses everything from this module:
// src/index.js import express from 'express'; import { randomUUID } from 'node:crypto';
const app = express(); app.use(express.json());
// In-memory "database" const users = new Map();
// GET /users — list all users app.get('/users', (req, res) => { const { role } = req.query; let result = Array.from(users.values()); if (role) result = result.filter(u => u.role === role); res.json(result); });
// GET /users/:id — get one user app.get('/users/:id', (req, res) => { const user = users.get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); res.json(user); });
// POST /users — create a user app.post('/users', (req, res) => { const { name, email } = req.body; if (!name || !email) { return res.status(400).json({ error: 'name and email are required' }); }
const user = { id: randomUUID(), name, email, role: 'user', createdAt: new Date() }; users.set(user.id, user); res.status(201).json(user); });
// PATCH /users/:id — update a user app.patch('/users/:id', (req, res) => { const user = users.get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' });
const updated = { ...user, ...req.body, id: user.id }; // id is immutable users.set(user.id, updated); res.json(updated); });
// DELETE /users/:id — delete a user app.delete('/users/:id', (req, res) => { if (!users.has(req.params.id)) { return res.status(404).json({ error: 'User not found' }); } users.delete(req.params.id); res.status(204).send(); });
// Global error handler app.use((err, req, res, next) => { console.error(err); res.status(500).json({ error: 'Internal server error' }); });
app.listen(3000, () => console.log('API running on http://localhost:3000'));
Test it with curl:
# Create a user curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name":"Jatin","email":"jatin@example.com"}'
# List users curl http://localhost:3000/users
# Get user by id (use the id returned from POST) curl http://localhost:3000/users/<id>