Building a REST API with Express.js — Routes, Middleware and Real Patterns
Every app you use daily — Spotify, GitHub, your bank's mobile app — talks to a server through an API. When Spotify's mobile app asks 'give me this user's playlists', it sends an HTTP request to a REST API, which fetches the data and sends it back as JSON. Express.js is the most popular framework for building those APIs in Node.js, and for good reason: it's minimal, fast, and gives you exactly as much structure as you need without forcing a rigid pattern on you.
Before Express existed, building an HTTP server in raw Node.js meant writing dozens of lines of boilerplate just to read a URL or parse a request body. Express solves that. It wraps Node's built-in http module with a clean, chainable API for defining routes, plugging in middleware, and sending structured responses. The result is that you can go from zero to a working API endpoint in about ten lines of code — but knowing why each of those lines exists is what separates a junior who copies tutorials from an engineer who can debug, scale, and maintain a real service.
By the end of this article, you'll have a fully working RESTful API for a book library — complete with all four CRUD operations, proper HTTP status codes, input validation middleware, and a global error handler. More importantly, you'll understand the mental model behind each decision so you can apply these patterns to any domain, not just this example.
What REST Actually Means and Why Express Fits It Perfectly
REST stands for Representational State Transfer. It's an architectural style — a set of rules about how clients and servers should communicate over HTTP. The key constraint that matters most day-to-day is this: every resource (a user, a book, an order) gets its own URL, and you use HTTP verbs — GET, POST, PUT, DELETE — to describe what action you want to perform on it.
This means GET /books fetches all books. POST /books creates a new one. GET /books/42 fetches the book with ID 42. PUT /books/42 updates it. DELETE /books/42 removes it. The URL is the noun, the HTTP verb is the verb. This predictable contract is why REST APIs are so easy to consume — any client in any language can call them.
Express maps directly onto this mental model. It lets you register a handler for a specific verb + URL combination, called a route. When a request arrives that matches, Express runs your handler. That's the core loop. Everything else — middleware, error handling, routers — is built on top of that simple idea.
Understanding this upfront matters because beginners often think Express is REST. It isn't. Express is a tool. REST is the design philosophy. You could build a badly designed, non-RESTful API with Express just as easily. The goal of this article is to show you what a well-designed one looks like.
// server.js — the entry point for our Book Library REST API // Run: node server.js // Then visit: http://localhost:3000/api/books const express = require('express'); const app = express(); // create an Express application instance const PORT = 3000; // express.json() is middleware that parses incoming request bodies // that have Content-Type: application/json. Without this, req.body is undefined. app.use(express.json()); // In-memory data store — we'll replace this pattern with a real DB later // but the API shape stays identical, which is the whole point of abstraction let books = [ { id: 1, title: 'The Pragmatic Programmer', author: 'Hunt & Thomas', year: 1999 }, { id: 2, title: 'Clean Code', author: 'Robert C. Martin', year: 2008 }, { id: 3, title: 'You Don\'t Know JS', author: 'Kyle Simpson', year: 2015 }, ]; // GET /api/books — retrieve all books // REST rule: GET never modifies data, always returns a collection or single resource app.get('/api/books', (req, res) => { res.status(200).json({ success: true, count: books.length, data: books, }); }); // GET /api/books/:id — retrieve one book by its ID // :id is a route parameter — Express captures whatever is in that URL segment app.get('/api/books/:id', (req, res) => { const bookId = parseInt(req.params.id, 10); // always parse to a number — URL params are strings const book = books.find((b) => b.id === bookId); if (!book) { // 404 means the resource doesn't exist — not a server error, a client error return res.status(404).json({ success: false, message: `Book with id ${bookId} not found` }); } res.status(200).json({ success: true, data: book }); }); // POST /api/books — create a new book // The client sends book data in the request body as JSON app.post('/api/books', (req, res) => { const { title, author, year } = req.body; // Basic validation — never trust client input if (!title || !author || !year) { return res.status(400).json({ success: false, message: 'title, author, and year are required fields', }); } // Generate a simple incrementing ID (a real DB would handle this) const newBook = { id: books.length > 0 ? Math.max(...books.map((b) => b.id)) + 1 : 1, title, author, year: parseInt(year, 10), }; books.push(newBook); // 201 Created — not 200. The resource didn't exist before; now it does. res.status(201).json({ success: true, data: newBook }); }); // PUT /api/books/:id — replace/update an existing book app.put('/api/books/:id', (req, res) => { const bookId = parseInt(req.params.id, 10); const bookIndex = books.findIndex((b) => b.id === bookId); if (bookIndex === -1) { return res.status(404).json({ success: false, message: `Book with id ${bookId} not found` }); } const { title, author, year } = req.body; // Merge the existing book data with the incoming updates books[bookIndex] = { ...books[bookIndex], title, author, year: parseInt(year, 10) }; res.status(200).json({ success: true, data: books[bookIndex] }); }); // DELETE /api/books/:id — remove a book app.delete('/api/books/:id', (req, res) => { const bookId = parseInt(req.params.id, 10); const bookIndex = books.findIndex((b) => b.id === bookId); if (bookIndex === -1) { return res.status(404).json({ success: false, message: `Book with id ${bookId} not found` }); } books.splice(bookIndex, 1); // remove the book from our array // 204 No Content — success, but there's nothing to return (the resource is gone) res.status(204).send(); }); app.listen(PORT, () => { console.log(`Book Library API running on http://localhost:${PORT}`); });
# GET /api/books
{ "success": true, "count": 3, "data": [ ...all 3 books... ] }
# POST /api/books with body { "title": "Refactoring", "author": "Martin Fowler", "year": 2018 }
{ "success": true, "data": { "id": 4, "title": "Refactoring", "author": "Martin Fowler", "year": 2018 } }
# DELETE /api/books/2
(empty body — HTTP 204 No Content)
Middleware — The Assembly Line That Runs Before Your Route Handler
Middleware is Express's killer feature, and it's the concept most beginners underestimate. A middleware function is just a function with three arguments: req, res, and next. When Express receives a request, it runs it through a pipeline of middleware functions in the order they were registered. Each function can read the request, modify it, respond to it, or pass control to the next function by calling next().
Think of it like airport security. Before you reach your gate (the route handler), your bag goes through X-ray (logging middleware), you show your passport (authentication middleware), and you get patted down (validation middleware). If any step fails, the process stops and you don't board the plane.
This pattern is powerful because it separates concerns cleanly. Your route handler shouldn't care about logging or authentication — it should only care about its specific business logic. Middleware handles the cross-cutting concerns that apply across many routes.
There are three types you'll use constantly: application-level middleware (registered with app.use()), router-level middleware (scoped to a specific Router instance), and error-handling middleware (four-argument functions: err, req, res, next). The order of registration is everything — middleware registered later in the file won't intercept requests that were already responded to by earlier handlers.
// middleware.js — custom middleware functions for our Book Library API // These are pure functions: testable, reusable, and single-purpose // ─── REQUEST LOGGER ─────────────────────────────────────────────────────────── // Logs every incoming request: method, URL, and response time // This runs for EVERY request because we'll register it with app.use() at the top const requestLogger = (req, res, next) => { const startTime = Date.now(); // res.on('finish') fires AFTER the response is sent — so we get accurate timing res.on('finish', () => { const duration = Date.now() - startTime; console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} — ${res.statusCode} (${duration}ms)`); }); next(); // CRITICAL: without next(), the request just hangs — no response ever sent }; // ─── API KEY AUTHENTICATION ──────────────────────────────────────────────────── // Checks that the client sent a valid API key in the x-api-key header // In production you'd validate against a database or use JWT — same pattern, more logic const requireApiKey = (req, res, next) => { const VALID_API_KEY = process.env.API_KEY || 'dev-secret-key-12345'; const clientKey = req.headers['x-api-key']; if (!clientKey) { // 401 Unauthorized — the client didn't provide credentials at all return res.status(401).json({ success: false, message: 'Missing x-api-key header. Include your API key to access this endpoint.', }); } if (clientKey !== VALID_API_KEY) { // 403 Forbidden — credentials were provided but they're wrong return res.status(403).json({ success: false, message: 'Invalid API key. Check your credentials and try again.', }); } // Attach the key to the request object for downstream use if needed req.apiKey = clientKey; next(); // credentials are valid — continue to the route handler }; // ─── BOOK BODY VALIDATOR ─────────────────────────────────────────────────────── // Validates that POST and PUT requests have the required book fields // By extracting this to middleware, our route handlers stay clean const validateBookBody = (req, res, next) => { const { title, author, year } = req.body; const errors = []; if (!title || typeof title !== 'string' || title.trim() === '') { errors.push('title must be a non-empty string'); } if (!author || typeof author !== 'string' || author.trim() === '') { errors.push('author must be a non-empty string'); } if (!year || isNaN(parseInt(year, 10)) || parseInt(year, 10) < 1000) { errors.push('year must be a valid 4-digit number'); } if (errors.length > 0) { // Pass an Error object to next() — this triggers Express's error handler const validationError = new Error('Validation failed'); validationError.statusCode = 400; validationError.details = errors; return next(validationError); // jumps straight to the error-handling middleware } next(); }; // ─── GLOBAL ERROR HANDLER ────────────────────────────────────────────────────── // This MUST have exactly 4 parameters — Express detects it as an error handler by signature // Register this LAST with app.use() — after all routes const globalErrorHandler = (err, req, res, next) => { console.error(`[ERROR] ${err.message}`, err.stack); const statusCode = err.statusCode || 500; // default to 500 if no specific code was set const response = { success: false, message: err.message || 'An unexpected server error occurred', }; // Include validation details if present (avoids leaking stack traces to clients) if (err.details) { response.errors = err.details; } res.status(statusCode).json(response); }; module.exports = { requestLogger, requireApiKey, validateBookBody, globalErrorHandler };
[2024-01-15T10:23:45.123Z] GET /api/books — 200 (3ms)
[2024-01-15T10:23:51.456Z] POST /api/books — 400 (1ms)
# Missing API key:
{ "success": false, "message": "Missing x-api-key header. Include your API key to access this endpoint." }
# Validation failure:
{ "success": false, "message": "Validation failed", "errors": ["year must be a valid 4-digit number"] }
Express Router — Organising Routes Like a Real-World Codebase
When your API has more than one resource — say books, authors, and orders — putting every route in a single server.js file becomes unmanageable fast. Express Router solves this by letting you create mini-applications that handle a subset of routes, then mount them onto your main app at a specific path prefix.
Think of it like a post office. The main post office (your app) receives all mail. It then hands packages destined for '42nd Street' to the 42nd Street department (your /api/books Router), packages for '5th Avenue' to a different department, and so on. Each department handles its own internal sorting without the main post office needing to know the details.
This isn't just an organisational preference — it's the pattern that makes your codebase testable and scalable. Each Router module can be imported, tested independently, and even reused. When a new developer joins your team, they can look at routes/books.js to understand everything about the books resource without reading the entire codebase.
The middleware cascade works here too: any middleware registered on the Router only runs for routes within that Router. This is how you can protect your entire /api/admin route group with auth middleware without touching any other route.
// routes/books.js — all book-related routes live here // This file knows NOTHING about auth or logging — those are app-level concerns const express = require('express'); const router = express.Router(); // create a Router instance — not a full app const { validateBookBody } = require('../middleware'); // Simulated data layer — in a real app this would be a service/repository // The key point: swapping this to a real DB doesn't change the routes at all let books = [ { id: 1, title: 'The Pragmatic Programmer', author: 'Hunt & Thomas', year: 1999 }, { id: 2, title: 'Clean Code', author: 'Robert C. Martin', year: 2008 }, ]; // GET / — maps to GET /api/books when mounted at /api/books // Notice: the path here is '/', not '/api/books' — the prefix is added at mount time router.get('/', (req, res) => { // req.query lets you read URL query parameters like ?author=Martin const { author, year } = req.query; let results = [...books]; if (author) { results = results.filter((b) => b.author.toLowerCase().includes(author.toLowerCase()) ); } if (year) { results = results.filter((b) => b.year === parseInt(year, 10)); } res.status(200).json({ success: true, count: results.length, data: results }); }); // GET /:id — maps to GET /api/books/:id router.get('/:id', (req, res) => { const bookId = parseInt(req.params.id, 10); const book = books.find((b) => b.id === bookId); if (!book) { return res.status(404).json({ success: false, message: `Book ${bookId} not found` }); } res.status(200).json({ success: true, data: book }); }); // POST / — validateBookBody middleware runs BEFORE the route handler // If validation fails, the route handler never executes — the error bubbles to globalErrorHandler router.post('/', validateBookBody, (req, res) => { const { title, author, year } = req.body; const newBook = { id: books.length > 0 ? Math.max(...books.map((b) => b.id)) + 1 : 1, title: title.trim(), author: author.trim(), year: parseInt(year, 10), }; books.push(newBook); res.status(201).json({ success: true, data: newBook }); }); router.put('/:id', validateBookBody, (req, res) => { const bookId = parseInt(req.params.id, 10); const bookIndex = books.findIndex((b) => b.id === bookId); if (bookIndex === -1) { return res.status(404).json({ success: false, message: `Book ${bookId} not found` }); } books[bookIndex] = { id: bookId, ...req.body, year: parseInt(req.body.year, 10) }; res.status(200).json({ success: true, data: books[bookIndex] }); }); router.delete('/:id', (req, res) => { const bookId = parseInt(req.params.id, 10); const bookIndex = books.findIndex((b) => b.id === bookId); if (bookIndex === -1) { return res.status(404).json({ success: false, message: `Book ${bookId} not found` }); } books.splice(bookIndex, 1); res.status(204).send(); }); module.exports = router; // ─── app.js — main application file that wires everything together ───────────── // (In a real project this would be a separate file) const appSetup = ` const express = require('express'); const { requestLogger, requireApiKey, globalErrorHandler } = require('./middleware'); const bookRouter = require('./routes/books'); const app = express(); app.use(express.json()); // parse JSON bodies — runs for ALL routes app.use(requestLogger); // log every request — runs for ALL routes app.use(requireApiKey); // auth gate — runs for ALL routes below this line // Mount the book router at /api/books // All routes in bookRouter are now prefixed with /api/books app.use('/api/books', bookRouter); // 404 handler — catches any request that didn't match a route above app.use((req, res) => { res.status(404).json({ success: false, message: 'Route not found' }); }); // Error handler — MUST be registered last, MUST have 4 params app.use(globalErrorHandler); app.listen(3000, () => console.log('API running on http://localhost:3000')); `; console.log('Route structure:\n', appSetup);
{ "success": true, "count": 1, "data": [{ "id": 2, "title": "Clean Code", "author": "Robert C. Martin", "year": 2008 }] }
# POST /api/books with missing year field
{ "success": false, "message": "Validation failed", "errors": ["year must be a valid 4-digit number"] }
# GET /api/books/999
{ "success": false, "message": "Book 999 not found" }
# GET /nonexistent-route
{ "success": false, "message": "Route not found" }
| Aspect | app.use() (Application Middleware) | router.use() (Router Middleware) |
|---|---|---|
| Scope | Applies to every route in the entire app | Applies only to routes in that specific Router instance |
| Use case | Logging, JSON parsing, CORS, global auth | Route-group auth (e.g. /admin only), group-specific validation |
| Registration | Called directly on the Express app object | Called on an express.Router() instance |
| Execution order | Runs in the order app.use() calls appear in app.js | Runs in order of router.use() calls within that router file |
| Error handling | Catches errors from all routes below it | Only catches errors from routes within that router |
| Testability | Harder to unit test in isolation | Easy to test the router module independently with supertest |
🎯 Key Takeaways
- HTTP verbs are the verb, URLs are the noun — GET /books fetches, POST /books creates, PUT /books/:id updates, DELETE /books/:id removes. Never use verbs in REST URLs like /getBooks or /createUser.
- Middleware order is execution order — app.use(express.json()) must come before any route that reads req.body, and globalErrorHandler must always be the last app.use() call in your file.
- Calling next(err) with an Error object is how you route to Express's error-handling middleware — and that error handler must have exactly four parameters (err, req, res, next) or Express won't recognise it as an error handler.
- Express Router lets you scope routes and middleware to a specific URL prefix — mount a router with app.use('/api/books', bookRouter) and every route inside it is automatically prefixed, keeping your codebase modular and testable.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Not calling next(err) in async route handlers — If an async operation throws inside a route handler and you don't catch it, the error is swallowed silently and the client gets no response (then a timeout). Fix: wrap async handlers in a try/catch and call next(err), or use a wrapper like express-async-errors that does this automatically for all async routes.
- ✕Mistake 2: Registering the global error handler before your routes — The error handler must come LAST in your app.js, after all routes and other middleware. If you register it first, it'll never receive errors because the request pipeline hasn't reached your routes yet. Symptom: errors produce no response or fall through to Express's default HTML error page even though you defined a handler.
- ✕Mistake 3: Using 200 for all responses regardless of outcome — Sending a 200 status code with
{ success: false }in the body breaks every HTTP client, proxy, monitoring tool, and frontend library that relies on status codes to detect failures. Axios, fetch, and tools like AWS API Gateway all branch on status codes. Use 400 for bad input, 401/403 for auth failures, 404 for missing resources, and 500 for unexpected server errors — always.
Interview Questions on This Topic
- QWhat is the difference between 401 Unauthorized and 403 Forbidden, and when would your Express API return each one?
- QExplain the Express middleware pipeline. If I have five app.use() calls and a route handler, what determines the execution order, and what happens if one middleware never calls next()?
- QHow would you handle errors in an async/await route handler in Express, given that try/catch boilerplate repeated across 20 routes becomes a maintenance problem? Walk me through at least two approaches.
Frequently Asked Questions
Do I need Express to build a REST API in Node.js?
No — you can use Node's built-in http module. But you'd manually parse URLs, handle routing logic, parse JSON bodies, and manage every edge case yourself. Express abstracts all of that into a clean API. Most production teams use Express or a framework built on top of it (like NestJS or Fastify) precisely because the boilerplate savings are significant and the patterns are battle-tested.
What is the difference between req.params, req.query, and req.body in Express?
req.params holds values from URL segments defined with a colon, like the 42 in /books/42 when your route is /books/:id. req.query holds key-value pairs from the URL query string, like ?author=Martin. req.body holds data sent in the HTTP request body — typically JSON sent with a POST or PUT request, available only after you've registered the express.json() middleware.
Why does my Express error handler never seem to run?
There are two common causes. First, your error handler must be registered AFTER all routes with app.use() — if it's above your routes, requests never reach it before being handled. Second, for async route handlers, errors thrown inside async functions must be explicitly caught and passed to next(err). An uncaught promise rejection doesn't automatically flow into Express's error pipeline unless you're using Node 18+ with unhandledRejection hooks or a wrapper like express-async-errors.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.