Skip to content
Express.js ex routing 4 min read

Multiple Route Handlers

A route in Express does not have to be served by a single function. You can attach several handlers to one route and let Express run them in sequence, each one calling next() to pass control to the one after it. This turns a route into a small pipeline where you can split cross-cutting concerns — authentication, validation, logging — away from the core business logic, keeping every function short and focused.

Passing more than one handler

Every routing method (app.get, app.post, app.use, and the rest) accepts any number of handler functions after the path. Express stores them as an ordered chain and invokes them one at a time. A handler must either send a response or call next(); calling next() advances to the next handler in the chain.

const express = require("express");
const app = express();

function logRequest(req, res, next) {
  console.log(`${req.method} ${req.url}`);
  next(); // hand control to the next handler
}

function loadUser(req, res, next) {
  req.user = { id: 1, name: "Ada", role: "admin" };
  next();
}

app.get("/profile", logRequest, loadUser, (req, res) => {
  res.json({ greeting: `Hello, ${req.user.name}` });
});

app.listen(3000);

A request to GET /profile walks all three functions in order. The first logs, the second attaches data to req, and the third — the final handler — sends the response.

Output:

GET /profile
{ "greeting": "Hello, Ada" }

Notice how earlier handlers communicate with later ones by decorating the req object (here, req.user). The request object is shared across the whole chain, so anything you set on it is visible downstream.

Calling next() to pass control

next() is the single most important call in a multi-handler route. It tells Express “I’m done; run the next thing.” Forgetting to call it (and forgetting to send a response) leaves the request hanging forever.

There are three ways a handler can behave:

CallEffect
next()Continue to the next handler in this route’s chain
next("route")Skip the rest of this route’s handlers and try the next matching route
next(err)Skip ahead to error-handling middleware
res.send(...) / res.json(...)End the request; later handlers do not run

The special next("route") form is only available inside route handlers (not generic middleware) and is handy for bailing out of a chain early:

function adminOnly(req, res, next) {
  if (req.user?.role !== "admin") return next("route");
  next();
}

app.get("/dashboard", adminOnly, (req, res) => {
  res.send("Admin dashboard");
});

app.get("/dashboard", (req, res) => {
  res.status(403).send("Forbidden");
});

If adminOnly calls next("route"), Express abandons the first route’s remaining handlers and falls through to the second GET /dashboard, which returns 403.

Guards plus logic separation

The most common reason to chain handlers is to separate guards (checks that decide whether the request may proceed) from the logic that produces the response. A guard either passes the request along or short-circuits it with an error response.

function authenticate(req, res, next) {
  const token = req.headers.authorization;
  if (!token) return res.status(401).json({ error: "Unauthorized" });
  req.userId = "user_42";
  next();
}

function validateBody(req, res, next) {
  if (!req.body?.title) {
    return res.status(400).json({ error: "title is required" });
  }
  next();
}

app.post(
  "/articles",
  express.json(),
  authenticate,
  validateBody,
  async (req, res, next) => {
    try {
      const article = await db.createArticle(req.userId, req.body);
      res.status(201).json(article);
    } catch (err) {
      next(err);
    }
  }
);

Each guard has one job and exits early on failure, so the final async handler can assume the request is authenticated and valid. This reads top-to-bottom like a checklist and makes each piece independently testable.

Tip: A guard that rejects a request should send a response (e.g. res.status(401)...) and not call next(). Calling both sends headers twice and throws ERR_HTTP_HEADERS_SENT.

Passing an array of handlers

Instead of listing handlers as separate arguments, you can pass an array. Arrays and individual arguments can even be mixed, which is the idiomatic way to reuse a named group of middleware across several routes.

const requireAuth = [authenticate, validateBody];

app.post("/articles", express.json(), requireAuth, (req, res) => {
  res.status(201).json({ ok: true });
});

app.put("/articles/:id", express.json(), requireAuth, (req, res) => {
  res.json({ id: req.params.id, updated: true });
});

Express flattens the array into the chain, so the behavior is identical to listing each function inline — but you define the guard stack once and apply it wherever you need it.

Note: This works the same in Express 4 and 5. In Express 5, an async handler that rejects automatically forwards the error to your error middleware, so you can drop the manual try/catch and next(err) in many handlers.

Best Practices

  • Give each handler a single responsibility — one guard checks auth, another validates, a final one does the work.
  • Always end a handler by either sending a response or calling next(), never both.
  • Use next(err) to forward failures to centralized error middleware instead of responding inline.
  • Decorate req (e.g. req.user) to pass data from earlier handlers to later ones.
  • Group reusable middleware into a named array and apply it across routes for consistency.
  • Order matters: place guards before the logic handler so checks run first.
  • Reserve next("route") for deliberately skipping to an alternate route, and only inside route handlers.
Last updated June 14, 2026
Was this helpful?