Home JavaScript Building a REST API with Express.js — Routes, Middleware and Real Patterns

Building a REST API with Express.js — Routes, Middleware and Real Patterns

In Plain English 🔥
Imagine a restaurant. You (the customer) sit at a table and place an order. The waiter takes that order to the kitchen, the kitchen prepares the food, and the waiter brings it back. A REST API is exactly that waiter — it sits between your app (the customer) and your database or business logic (the kitchen). Express.js is the training manual that tells the waiter exactly how to behave: which orders to accept, in what format, and what to do when something goes wrong. You're not building the restaurant from scratch — you're hiring a very well-trained waiter.
⚡ Quick Answer
Imagine a restaurant. You (the customer) sit at a table and place an order. The waiter takes that order to the kitchen, the kitchen prepares the food, and the waiter brings it back. A REST API is exactly that waiter — it sits between your app (the customer) and your database or business logic (the kitchen). Express.js is the training manual that tells the waiter exactly how to behave: which orders to accept, in what format, and what to do when something goes wrong. You're not building the restaurant from scratch — you're hiring a very well-trained waiter.

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 · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// 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}`);
});
▶ Output
Book Library API running on http://localhost:3000

# 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)
🔥
Why HTTP Status Codes Matter:Sending 200 for everything is a red flag in a code review. Use 201 for successful creation, 204 for successful deletion with no response body, 400 for bad client input, 404 for missing resources, and 500 for unexpected server errors. Clients — including your own frontend code — use these codes to branch their logic. If everything is 200, your client has to parse the body to detect errors, which is brittle.

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 · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
// 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 };
▶ Output
# Every request logged automatically:
[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"] }
⚠️
Watch Out: Forgetting next() Hangs Your ServerIf your middleware doesn't call next() AND doesn't send a response, the HTTP request will sit there until the client times out. Express won't throw an error — it just silently hangs. Always make sure every code path in a middleware either calls next(), next(err), or sends a response. A linter rule like eslint-plugin-node can catch this pattern.

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 · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
// 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);
▶ Output
# GET /api/books?author=Martin
{ "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" }
⚠️
Pro Tip: Route Parameter Order Is a GotchaIf you define `router.get('/:id', ...)` before `router.get('/featured', ...)`, Express will match `/api/books/featured` as a request for book with id 'featured' — not your featured route. Always register specific, literal routes BEFORE parameterised ones. This is a real interview question too — interviewers love to ask why `/users/me` isn't matching as expected.
Aspectapp.use() (Application Middleware)router.use() (Router Middleware)
ScopeApplies to every route in the entire appApplies only to routes in that specific Router instance
Use caseLogging, JSON parsing, CORS, global authRoute-group auth (e.g. /admin only), group-specific validation
RegistrationCalled directly on the Express app objectCalled on an express.Router() instance
Execution orderRuns in the order app.use() calls appear in app.jsRuns in order of router.use() calls within that router file
Error handlingCatches errors from all routes below itOnly catches errors from routes within that router
TestabilityHarder to unit test in isolationEasy 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.

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousExpress.js FrameworkNext →Middleware in Express.js
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged