Skip to main content

🗺️ Routing and Middleware

Routing decides where a request goes; middleware decides what happens on the way. Together they are the beating heart of every Express app. This lesson takes you from a single app.get() to modular routers, chained handlers, and a production-grade error pipeline.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define routes for every HTTP method and read route parameters and query strings
  • Chain multiple handler functions to keep each one focused
  • Split an app into modular, mountable routers with express.Router()
  • Write custom middleware, including a configurable middleware factory
  • Build a centralized error-handling pipeline with custom error classes

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Build a modular users API with validation middleware and a global error handler.

In This Lesson

Routing Basics

Routing is how an app's endpoints (URIs) respond to client requests. Each route is defined by a method call in the shape app.METHOD(PATH, HANDLER), where METHOD is a lowercase HTTP verb, PATH is a URL pattern, and HANDLER is the function that runs on a match.

📮 The mail-sorting analogy. Routing is a post office. The HTTP method is the service class (standard, express, registered); the path is the destination department; a route parameter is a specific mailbox within it; and the handler is the clerk who processes that kind of mail for that department.
flowchart TD A[Client Request] --> B{Router} B -->|GET /users| C[List users] B -->|POST /users| D[Create user] B -->|GET /users/:id| E[Show user] B -->|PUT /users/:id| F[Update user] B -->|DELETE /users/:id| G[Delete user]
const express = require('express');
const app = express();
app.use(express.json());

app.get('/', (req, res) => res.send('Hello World!'));

app.post('/users', (req, res) => {
  res.status(201).json({ created: req.body });
});

app.put('/users/:id', (req, res) => {
  res.send(`Updated user ${req.params.id}`);
});

app.delete('/users/:id', (req, res) => {
  res.status(204).end();
});

app.listen(3000, () => console.log('Listening on 3000'));

Notice the status codes: 201 Created after a successful POST, and 204 No Content after a DELETE with nothing to return. Choosing accurate status codes is part of good routing, not an afterthought.

Route & Query Parameters

Express gives you two ways to receive data through the URL, and it's important to know which is which.

📖 Route parameters vs query parameters

Route parameters (/users/:id) are part of the path itself and identify which resource. They live on req.params.

Query parameters (/search?q=express&page=2) come after the ? and carry optional data like filters, sorting, and pagination. They live on req.query.

Route parameters — req.params

// One parameter
app.get('/users/:userId', (req, res) => {
  res.send(`User ID: ${req.params.userId}`);
});

// Nested resources with multiple parameters
app.get('/users/:userId/posts/:postId', (req, res) => {
  const { userId, postId } = req.params;
  res.json({ userId, postId });
});

A request to /users/123/posts/456 sets userId to "123" and postId to "456". The nested path mirrors the real relationship: posts belong to a user.

Query parameters — req.query

app.get('/api/products', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;
  const category = req.query.category;
  const minPrice = req.query.minPrice ? parseFloat(req.query.minPrice) : undefined;

  const filter = {};
  if (category) filter.category = category;
  if (minPrice !== undefined) filter.price = { $gte: minPrice };

  res.json({
    filter,
    pagination: { currentPage: page, itemsPerPage: limit }
  });
});

⚠️ Query values are always strings

req.query.page is the string "2", not the number 2. Convert with parseInt() / parseFloat() and always supply a sensible default, because query parameters are optional and may be missing.

Combined, a URL like /api/products?category=electronics&minPrice=100&page=2&limit=10 gives you filtering and pagination in one clean, bookmarkable endpoint.

Chained Route Handlers

A route can take more than one handler function. Each behaves like route-specific middleware: it runs in order, and any one of them can end the cycle or call next() to advance. This lets you break a request into small, single-purpose steps.

// Step 1: validate the id format
function validateUserId(req, res, next) {
  if (!/^[0-9a-fA-F]{24}$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID format' });
  }
  next();
}

// Step 2: load the user, attach to req
async function loadUser(req, res, next) {
  try {
    const user = await findUserById(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    req.user = user;      // hand off to the next function
    next();
  } catch (err) {
    next(err);            // forward to the error handler
  }
}

// Step 3: respond
function sendUser(req, res) {
  res.json(req.user);
}

app.get('/users/:id', validateUserId, loadUser, sendUser);

Each function does exactly one thing, which makes them reusable — validateUserId and loadUser can be shared across GET, PUT, and DELETE routes for the same resource.

Modular Routers

Piling every route into one file stops scaling quickly. express.Router() creates a mini-application — its own middleware and routes — that you can define in a separate file and mount at a path prefix.

flowchart TD A[Express App] --> B[User Router /api/users] A --> C[Product Router /api/products] B --> D[GET /] B --> E[POST /] B --> F[GET /:id] C --> G[GET /] C --> H[POST /] C --> I[GET /:id]

File: routes/userRoutes.js

const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');

router.get('/', userController.getAllUsers);
router.post('/', userController.createUser);
router.get('/:id', userController.getUserById);
router.put('/:id', userController.updateUser);
router.delete('/:id', userController.deleteUser);

module.exports = router;

File: app.js

const express = require('express');
const userRoutes = require('./routes/userRoutes');
const productRoutes = require('./routes/productRoutes');

const app = express();
app.use(express.json());

// Mount routers at their prefixes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);

app.listen(3000, () => console.log('Listening on 3000'));

Because the router is mounted at /api/users, its internal router.get('/') answers GET /api/users, and router.get('/:id') answers GET /api/users/:id. Each resource gets its own file, and the main app stays a short table of contents.

💡 Router-level middleware

A router can carry its own middleware with router.use(...). Put an auth guard there and it protects every route on that router without repeating it — the perfect place to scope concerns to one resource.

Writing Custom Middleware

A middleware is just a function with the signature (req, res, next). The most flexible pattern is a middleware factory: a function that takes options and returns a configured middleware. This lets you reuse one middleware with different settings on different routes.

// A configurable rate limiter (in-memory demo)
function rateLimit({ windowMs = 60_000, max = 100, message = 'Too many requests' } = {}) {
  const hits = new Map();

  return (req, res, next) => {
    const ip = req.ip;
    const now = Date.now();
    const entry = hits.get(ip);

    if (!entry || now - entry.start > windowMs) {
      hits.set(ip, { count: 1, start: now });
      return next();
    }

    entry.count++;
    if (entry.count > max) {
      return res.status(429).json({ error: message });
    }
    next();
  };
}

// Generous limit globally...
app.use(rateLimit({ windowMs: 15 * 60_000, max: 100 }));

// ...but strict on login
app.use('/api/login', rateLimit({ windowMs: 60 * 60_000, max: 5 }));

⚠️ In-memory state doesn't survive scaling

The Map above lives in one process. Run two instances behind a load balancer and each keeps its own counts. For real deployments use a shared store (Redis) via a library like express-rate-limit. The factory pattern, though, is exactly how those libraries work internally.

The same factory idea powers authentication guards, feature flags, and role checks — anywhere you want "the same behavior, tuned per route."

Error-Handling Middleware

Error-handling middleware is special: Express recognizes it by its four arguments — (err, req, res, next). When any middleware calls next(err) with an argument, Express skips all remaining regular middleware and jumps straight to the first error handler.

flowchart LR A[Route handler] -->|"next(err)"| B[Error middleware] A -->|"res.json"| C[Normal response] B --> D[Formatted error response]

The professional pattern pairs custom error classes with a single central handler. The classes carry a status code and mark expected ("operational") errors so you can safely show their message to clients:

// errors.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(msg = 'Resource not found') { super(msg, 404); }
}
class ValidationError extends AppError {
  constructor(msg = 'Validation failed') { super(msg, 400); }
}

module.exports = { AppError, NotFoundError, ValidationError };
// A route that throws, then forwards to the handler
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await findUserById(req.params.id);
    if (!user) throw new NotFoundError(`User ${req.params.id} not found`);
    res.json(user);
  } catch (err) {
    next(err); // hand off to the error-handling middleware
  }
});

// The ONE central error handler — registered LAST
app.use((err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';

  if (process.env.NODE_ENV === 'development') {
    return res.status(err.statusCode).json({
      status: err.status, message: err.message, stack: err.stack
    });
  }

  // Production: only leak messages for trusted, operational errors
  if (err.isOperational) {
    return res.status(err.statusCode).json({ status: err.status, message: err.message });
  }
  console.error('UNEXPECTED ERROR', err);
  res.status(500).json({ status: 'error', message: 'Something went wrong' });
});

💡 Register the error handler last

Because middleware runs top to bottom, the error handler must be the last app.use() — after all routes. Only then can every route's next(err) reach it.

Hands-on Exercise

🏋️ A modular users API with validation and error handling

Objective: combine everything above into a small, well-structured API.

Instructions:

  1. Create routes/userRoutes.js using express.Router() with GET /, POST /, and GET /:id.
  2. Write a validateId middleware that returns 400 if :id is not a positive integer, using next() on success.
  3. In the GET /:id handler, throw a NotFoundError when the user is missing and forward it with next(err).
  4. Mount the router at /api/users and add a central error handler last.
  5. Test /api/users/abc (expect 400), /api/users/999 (expect 404), and /api/users/1 (expect 200).
💡 Hint

Validate the id with /^\d+$/.test(req.params.id). Keep the error classes in their own module and require them where thrown. Remember: the error handler needs all four parameters, even if you don't use next.

✅ Sample solution
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const { NotFoundError } = require('../errors');

const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];

function validateId(req, res, next) {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid id' });
  }
  next();
}

router.get('/', (req, res) => res.json(users));

router.post('/', (req, res) => {
  const user = { id: users.length + 1, name: req.body.name };
  users.push(user);
  res.status(201).json(user);
});

router.get('/:id', validateId, (req, res, next) => {
  const user = users.find(u => u.id === Number(req.params.id));
  if (!user) return next(new NotFoundError(`User ${req.params.id} not found`));
  res.json(user);
});

module.exports = router;
// app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const app = express();

app.use(express.json());
app.use('/api/users', userRoutes);

app.use((err, req, res, next) => {
  const code = err.statusCode || 500;
  res.status(code).json({ error: err.message });
});

app.listen(3000, () => console.log('Listening on 3000'));

🎯 Quick Quiz

Question 1: Where does Express put the value 42 from a request to /items/42 defined as /items/:id?

Question 2: How does Express know a middleware is an error handler?

Question 3: What is the main benefit of express.Router()?

Best Practices

✅ Do

  • Use route parameters to identify resources and query parameters for optional filters/pagination.
  • Split routes into per-resource routers and mount them under an /api prefix.
  • Break complex routes into small chained handlers you can reuse.
  • Wrap async handlers in try/catch (or a wrapper) and forward errors with next(err).
  • Register one central error handler last, and distinguish operational from unexpected errors.

⚠️ Don't

  • Don't trust query values as numbers — they're strings; parse and default them.
  • Don't scatter error formatting across every route; centralize it.
  • Don't leak stack traces or internal messages to clients in production.
  • Don't rely on in-memory middleware state once you run more than one instance.

Summary & Quiz

🎉 Key Takeaways

  • Routes follow app.METHOD(PATH, HANDLER); pick accurate status codes.
  • Route parameters (req.params) identify resources; query parameters (req.query) carry optional data and are always strings.
  • Chaining handlers keeps each step focused and reusable.
  • express.Router() turns a growing app into modular, mountable pieces.
  • A four-argument, centrally-registered error handler — plus custom error classes — makes failures predictable and safe.

📚 Further Reading

🚀 What's Next?

You can route requests and handle errors cleanly. Next we'll turn responses into rich HTML pages using template engines — EJS, Pug, and Handlebars — so your server can render dynamic views, not just JSON.

🎉 Well routed!

Your API now has structure and guardrails. Let's give it a face.