Back/Module F-6 Building HTTP Servers and REST APIs with Express
Module F-6·22 min read

The built-in http module, why Express exists, routing, middleware pipelines, route parameters, query strings, and sending JSON responses.

Module F-6 — Building HTTP Servers and REST APIs with Express

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.


From Raw http to Express

You saw the built-in http module in F-1:

javascript
const http = require('node:http'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World\n'); }); server.listen(3000);

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.

bash
npm install express

Your First Express Server

javascript
// src/index.js import express from 'express'; const app = express(); const PORT = process.env.PORT || 3000; // Middleware: parse incoming JSON bodies app.use(express.json()); // Route: GET / app.get('/', (req, res) => { res.json({ message: 'Hello from Express!' }); }); // Start listening app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });
bash
node src/index.js curl http://localhost:3000/ # {"message":"Hello from Express!"}

The Express API:

  • express() creates an application instance
  • app.use(...) adds middleware (runs on every request)
  • app.get(path, handler) registers a GET route
  • req — the incoming request object
  • res — the response object
  • res.json(data) — sends JSON with Content-Type: application/json and status 200

HTTP Methods and REST Conventions

REST APIs use HTTP methods to express intent:

MethodMeaningExample
GETRead a resourceGET /users — list all users
POSTCreate a resourcePOST /users — create a user
PUTReplace a resource entirelyPUT /users/42 — replace user 42
PATCHUpdate part of a resourcePATCH /users/42 — update user 42's email
DELETERemove a resourceDELETE /users/42 — delete user 42

Express has a method for each:

javascript
app.get('/users', (req, res) => { /* list users */ }); app.post('/users', (req, res) => { /* create user */ }); app.put('/users/:id', (req, res) => { /* replace user */ }); app.patch('/users/:id', (req, res) => { /* update user */ }); app.delete('/users/:id', (req, res) => { /* delete user */ });

Route Parameters

Route parameters are named placeholders in the URL path, prefixed with :. Express captures them and puts them in req.params.

javascript
// :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 }); }); // Multiple parameters app.get('/users/:userId/posts/:postId', (req, res) => { const { userId, postId } = req.params; res.json({ userId, postId }); });
bash
curl http://localhost:3000/users/42 # {"userId":"42"} curl http://localhost:3000/users/42/posts/7 # {"userId":"42","postId":"7"}

Important: route parameters are always strings, even if the URL contains a number. Convert them explicitly:

javascript
app.get('/users/:id', async (req, res) => { const id = parseInt(req.params.id, 10); if (isNaN(id)) { return res.status(400).json({ error: 'id must be a number' }); } const user = await db.findUser(id); if (!user) { return res.status(404).json({ error: 'User not found' }); } res.json(user); });

Query Strings

Query strings are the ?key=value part of a URL. Express parses them automatically into req.query.

javascript
// GET /users?role=admin&active=true&page=2&limit=20 app.get('/users', async (req, res) => { const { role, active, page = '1', limit = '20' } = req.query; const pageNum = parseInt(page, 10); const limitNum = parseInt(limit, 10); const isActive = active === 'true'; const users = await db.findUsers({ role, active: isActive, pageNum, limitNum }); res.json(users); });
bash
curl "http://localhost:3000/users?role=admin&active=true&page=1&limit=10"

Query string values are always strings — convert them as needed.


Request Bodies

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.

javascript
// 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 });
bash
curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name": "Jatin", "email": "jatin@example.com"}' # {"id": 1, "name": "Jatin", "email": "jatin@example.com", "role": "user"}

For URL-encoded forms (HTML form submissions):

javascript
app.use(express.urlencoded({ extended: true }));

HTTP Status Codes

Always respond with the appropriate status code. Clients use these to understand what happened.

javascript
// 200 OK — successful GET, PUT, PATCH res.status(200).json(data); res.json(data); // 200 is the default // 201 Created — successful POST that created a resource res.status(201).json(newUser); // 204 No Content — successful DELETE (no body to return) res.status(204).send(); // 400 Bad Request — client sent invalid data res.status(400).json({ error: 'email is required' }); // 401 Unauthorized — not authenticated res.status(401).json({ error: 'Authentication required' }); // 403 Forbidden — authenticated but not authorised res.status(403).json({ error: 'You do not have permission' }); // 404 Not Found — resource does not exist res.status(404).json({ error: 'User not found' }); // 409 Conflict — resource already exists (e.g. duplicate email) res.status(409).json({ error: 'Email already registered' }); // 422 Unprocessable Entity — valid format but fails business rules res.status(422).json({ error: 'Age must be 18 or over' }); // 500 Internal Server Error — something broke on the server res.status(500).json({ error: 'Internal server error' });

Middleware

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
javascript
// 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

Writing a custom middleware

javascript
// Request logger middleware function requestLogger(req, res, next) { const start = Date.now(); // Override res.json to capture the response status res.on('finish', () => { const duration = Date.now() - start; console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`); }); next(); // Pass to the next middleware } app.use(requestLogger);

Middleware that adds data to the request

javascript
// Add a requestId to every request import { randomUUID } from 'node:crypto'; function addRequestId(req, res, next) { req.id = randomUUID(); res.setHeader('X-Request-Id', req.id); next(); } app.use(addRequestId); // Later in a route handler, req.id is available app.get('/users', (req, res) => { console.log('Request ID:', req.id); res.json([]); });

Middleware that can stop the chain

javascript
// 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 Router

As your API grows, keeping every route in index.js becomes unmanageable. Use express.Router() to split routes into separate files:

javascript
// src/routes/users.js import { Router } from 'express'; const router = Router(); router.get('/', async (req, res) => { const users = await db.findAll(); res.json(users); }); router.get('/:id', async (req, res) => { const user = await db.findById(parseInt(req.params.id)); if (!user) return res.status(404).json({ error: 'Not found' }); res.json(user); }); router.post('/', async (req, res) => { const user = await db.create(req.body); res.status(201).json(user); }); router.delete('/:id', async (req, res) => { await db.delete(parseInt(req.params.id)); res.status(204).send(); }); export default router;
javascript
// src/routes/posts.js import { Router } from 'express'; const router = Router(); router.get('/', async (req, res) => { /* ... */ }); router.post('/', async (req, res) => { /* ... */ }); export default router;
javascript
// src/index.js import express from 'express'; import usersRouter from './routes/users.js'; import postsRouter from './routes/posts.js'; const app = express(); app.use(express.json()); // Mount routers at their base paths app.use('/users', usersRouter); app.use('/posts', postsRouter); // Routes: GET /users, POST /users, DELETE /users/:id // GET /posts, POST /posts app.listen(3000);

Error Handling

Express has a special error-handling middleware signature: four arguments (err, req, res, next). Register it after all routes.

javascript
// 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; res.status(err.statusCode || 500).json({ error: message }); });

We cover a much cleaner error handling pattern — with custom error classes and an async wrapper — in P-4. For now this structure works.


Reading Headers and Setting Response Headers

javascript
app.get('/protected', (req, res) => { // Read incoming headers const authHeader = req.headers['authorization']; const contentType = req.headers['content-type']; const userAgent = req.headers['user-agent']; // Set response headers res.setHeader('X-Custom-Header', 'value'); res.setHeader('Cache-Control', 'no-store'); res.json({ message: 'OK' }); });

A Complete Working Example

Here is a minimal in-memory users API that uses everything from this module:

javascript
// 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:

bash
# 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> # Update user curl -X PATCH http://localhost:3000/users/<id> \ -H "Content-Type: application/json" \ -d '{"role":"admin"}' # Delete user curl -X DELETE http://localhost:3000/users/<id>

Summary

  • Express wraps Node's http module with routing, middleware, and a clean API. Install with npm install express.
  • Routes are registered with app.get, app.post, app.put, app.patch, app.delete. The path and handler are separate concerns.
  • Route parameters (/users/:id) are in req.params. Always strings — convert them.
  • Query strings (?page=1&limit=20) are in req.query. Also always strings.
  • Request body (POST/PUT/PATCH) is in req.body after registering app.use(express.json()).
  • Status codes are set with res.status(code). Use them correctly — 201 for creation, 204 for deletion, 404 for not found, 400 for bad input.
  • Middleware runs in registration order. Call next() to continue, or respond to end the chain.
  • Routers split routes into files. Mount them with app.use('/prefix', router).
  • Error handler has four arguments (err, req, res, next) and must be registered last.

Next: connecting to a real database from Node.js — PostgreSQL with node-postgres, parameterized queries, and a first look at Prisma.

Discussion