Skip to main content

Command Palette

Search for a command to run...

Express Middleware: The Stuff That Runs Before Your Route Does Anything

Published
18 min read
Express Middleware: The Stuff That Runs Before Your Route Does Anything
S
Trying to transition my career to explore new things, new tech

I spent a full afternoon staring at a bug where every single route in my Express app returned undefined for req.body. The POST request was fine. The JSON was valid. The route handler was correct. I was losing it. Then someone on Discord said "did you add express.json()?" and I realized I had no idea what that line actually did. I had been copy-pasting it from tutorials without understanding it.

That line is middleware. And once I understood what middleware is, a lot of Express stopped feeling like magic and started making sense.


What middleware actually is

When a request hits your Express server, it does not go straight to the route handler. It passes through a series of functions first. Each of those functions can look at the request, modify it, send a response, or pass control to the next function in line. Those functions are middleware.

Think of it like an airport. You do not walk off the plane and straight into the city. You go through passport control, then customs, then baggage claim. Each checkpoint can let you through, send you back, or redirect you somewhere else. Your route handler is the exit door. Middleware is everything between landing and getting outside.

A middleware function in Express looks like this:

function myMiddleware(req, res, next) {
  // do something with req or res
  next(); // pass control to the next middleware or route
}

Three parameters. req is the incoming request object. res is the response object. next is a function that, when called, moves execution to the next middleware in the chain. If you do not call next() and you do not send a response, the request just hangs. The client waits forever. No error, no timeout message from Express, nothing. It just sits there. This will trip you up at least once.


Where middleware sits in the request lifecycle

Every HTTP request that reaches your Express app follows the same path. It enters the app, passes through middleware functions in the order they were registered, hits a matching route handler (if one exists), and a response goes back to the client.

Client sends request
       |
       v
  Express app receives it
       |
       v
  Middleware 1 (e.g., parse JSON body)
       |
       v
  Middleware 2 (e.g., log the request)
       |
       v
  Middleware 3 (e.g., check authentication)
       |
       v
  Route handler (your actual logic)
       |
       v
  Response sent back to client

The order matters. If your authentication middleware is registered after your route handler, the route runs without any auth check. Express processes middleware in the exact order you call app.use() or app.get() in your code. Move a line up or down and the behavior changes. This is not a design quirk. It is how the whole system works.


A minimal example to see it moving

Before getting into categories and theory, here is a working Express app with two middleware functions and one route. Run it locally and watch the console.

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

// Middleware 1: log every request
app.use((req, res, next) => {
  console.log(`\({req.method} \){req.url}`);
  next();
});

// Middleware 2: add a timestamp to the request object
app.use((req, res, next) => {
  req.requestTime = new Date().toISOString();
  next();
});

// Route handler
app.get('/', (req, res) => {
  res.json({ message: 'hello', time: req.requestTime });
});

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

Hit http://localhost:3000/ in your browser or with curl. The console prints something like:

GET /

And the response body looks like:

{
  "message": "hello",
  "time": "2025-01-15T10:23:45.123Z"
}

Middleware 1 logged the method and URL. Middleware 2 attached a timestamp to req. The route handler read that timestamp and sent it back. Each function did one thing, then called next() to let the next one run. That is the entire pattern.


Types of middleware

Express middleware falls into a few categories based on where and how you register it.

Application-level middleware

This is middleware registered on the app object using app.use() or app.METHOD(). It runs for every matching request, or for every request if no path is specified.

// runs for ALL requests to any route
app.use((req, res, next) => {
  console.log('this runs on every request');
  next();
});

// runs only for requests to /api/users
app.use('/api/users', (req, res, next) => {
  console.log('this only runs for /api/users routes');
  next();
});

The first version with no path argument is the most common. You see it for things like body parsing, CORS headers, and logging. The second version with a path lets you scope middleware to a specific section of your API without affecting other routes.

Router-level middleware

Express has a Router class that works like a mini-app. You can attach middleware to a router instead of the main app, and the middleware only applies to routes defined on that router.

const express = require('express');
const router = express.Router();

// this middleware only applies to routes on this router
router.use((req, res, next) => {
  console.log('admin router middleware');
  next();
});

router.get('/dashboard', (req, res) => {
  res.json({ page: 'admin dashboard' });
});

router.get('/users', (req, res) => {
  res.json({ page: 'manage users' });
});

// mount the router at /admin
app.use('/admin', router);

Now the middleware only runs when someone hits /admin/dashboard or /admin/users. A request to /api/products never touches it. This is how larger Express apps stay organized. You group related routes into routers, attach the middleware those routes need, and mount the router at a path. The main app file stays clean.

In a real project the file structure usually looks something like this:

routes/
  admin.js      <-- router with admin-only middleware
  auth.js       <-- router for login/signup
  orders.js     <-- router for order CRUD
server.js       <-- mounts all routers with app.use()

Built-in middleware

Express ships with three built-in middleware functions. You have probably used at least one without knowing it was middleware.

express.json() parses incoming JSON request bodies. Without it, req.body is undefined on POST and PUT requests. This is the one that got me at the start.

app.use(express.json());

app.post('/users', (req, res) => {
  console.log(req.body); // { name: "Priya", email: "priya@example.com" }
  res.json({ received: true });
});

Without that express.json() line:

app.post('/users', (req, res) => {
  console.log(req.body); // undefined
  res.json({ received: true });
});

No error. No warning. Just undefined. You find out when your database insert fails because you passed undefined as the document.

express.urlencoded({ extended: true }) does the same thing for form submissions. HTML forms send data in a format called application/x-www-form-urlencoded, and this middleware parses that into req.body.

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

The extended: true option uses the qs library for parsing, which supports nested objects in form data. With extended: false it uses the built-in querystring module, which only handles flat key-value pairs. For most apps, extended: true is what you want.

express.static() serves files from a directory. CSS, images, JavaScript files for the browser. You point it at a folder and Express serves everything in it.

app.use(express.static('public'));

A file at public/style.css becomes accessible at http://localhost:3000/style.css. No route handler needed.


Execution order and why it matters more than you think

I mentioned earlier that order matters. Here is a concrete example of what goes wrong when you get it backwards.

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

// Route defined BEFORE the JSON parser
app.post('/users', (req, res) => {
  console.log(req.body);  // undefined
  res.json({ user: req.body });
});

// JSON parser registered AFTER the route
app.use(express.json());

app.listen(3000);

Send a POST request with a JSON body to /users. req.body is undefined. The JSON parser exists in your code, but Express already matched the route and ran the handler before reaching the parser. By the time express.json() is registered, the request is already done.

Fix:

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

// Parser FIRST
app.use(express.json());

// Route SECOND
app.post('/users', (req, res) => {
  console.log(req.body);  // { name: "Priya" }
  res.json({ user: req.body });
});

app.listen(3000);

The general rule: put app.use() middleware at the top of your file, before any route definitions. Body parsers, CORS, logging, authentication checks. All of it goes above your routes. There are exceptions (error handlers go at the bottom, which I will get to), but this ordering keeps things predictable.

When you have multiple middleware, Express runs them in sequence:

app.use(middlewareA);
app.use(middlewareB);
app.use(middlewareC);

app.get('/test', handler);

A GET request to /test runs middlewareA, then middlewareB, then middlewareC, then handler. Each one has to call next() for the next one to run. If middlewareB does not call next(), middlewareC and handler never execute.


The next() function

next() is what connects the chain. Without it, the pipeline stops. There are three things you can do with it.

Call it with no arguments to pass control to the next middleware:

app.use((req, res, next) => {
  console.log('step 1');
  next(); // move to step 2
});

Do not call it and send a response instead, which ends the cycle:

app.use((req, res, next) => {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: 'No token provided' });
    // next() is never called. The chain stops here.
  }
  next();
});

Call it with an error to skip to the error-handling middleware:

app.use((req, res, next) => {
  try {
    // some operation that might throw
    const data = JSON.parse(req.headers['x-custom-data']);
    req.customData = data;
    next();
  } catch (err) {
    next(err); // jumps to the error handler
  }
});

Calling next(err) with an argument skips every regular middleware and route handler and goes straight to the first error-handling middleware. I will show what that looks like in a moment.

A common mistake: calling next() and then continuing to execute code after it.

app.use((req, res, next) => {
  if (!req.headers.authorization) {
    res.status(401).json({ error: 'unauthorized' });
    // forgot to return here
  }
  next(); // this still runs even after sending the 401
});

This causes the "Cannot set headers after they are sent to the client" error. The 401 response was already sent, then next() called the route handler which tried to send another response. The fix is either using return before next() in the else case, or adding return before the res.status() call:

app.use((req, res, next) => {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: 'unauthorized' });
  }
  next();
});

That return prevents any code below it from running after the response is sent.


Real-world examples

Request logging

In development you want to see every request that hits your server. What method, what URL, how long it took. A logging middleware handles this without touching any route.

app.use((req, res, next) => {
  const start = Date.now();

  // this runs after the response is sent
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`\({req.method} \){req.url} \({res.statusCode} \){duration}ms`);
  });

  next();
});

Output when you hit a few routes:

GET / 200 3ms
GET /api/users 200 12ms
POST /api/users 201 8ms
GET /api/users/999 404 2ms

The res.on('finish') listener fires after Express sends the response, so you get the actual status code and timing. Without that listener, you would have to log before the response was sent and you would not know the status code yet.

In production, most people use morgan instead of writing their own logger. It gives you configurable log formats and can write to files:

const morgan = require('morgan');
app.use(morgan('dev'));
GET / 200 2.540 ms
POST /api/users 201 15.230 ms

But writing your own first, like the example above, teaches you what morgan is doing under the hood.

Authentication

A common pattern in APIs: certain routes require the user to be logged in. Instead of checking authentication inside every route handler, you write a middleware that does it once.

const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const header = req.headers.authorization;

  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = header.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // attach user info to the request
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

You can apply it to specific routes:

// public routes, no auth needed
app.post('/api/login', loginHandler);
app.post('/api/signup', signupHandler);

// protected routes
app.get('/api/profile', authenticate, (req, res) => {
  // req.user exists here because authenticate put it there
  res.json({ user: req.user });
});

app.get('/api/orders', authenticate, (req, res) => {
  // same, req.user is available
  res.json({ orders: getUserOrders(req.user.id) });
});

Or apply it to an entire router:

const protectedRouter = express.Router();
protectedRouter.use(authenticate); // every route on this router requires auth

protectedRouter.get('/profile', profileHandler);
protectedRouter.get('/orders', ordersHandler);
protectedRouter.put('/settings', settingsHandler);

app.use('/api', protectedRouter);

The second approach is cleaner when you have many protected routes. You do not have to remember to add authenticate to each new route. If a route is on the protected router, it is protected.

A note about storing secrets: process.env.JWT_SECRET comes from a .env file loaded with dotenv. Do not hardcode the secret in your source code. A hardcoded secret that gets committed to Git is a leaked secret.

Request validation

You want to make sure the data coming into your API makes sense before your route handler tries to use it. Checking inside the handler works, but it gets repetitive fast. A validation middleware keeps the checking separate from the business logic.

Here is a simple version without any library:

function validateUser(req, res, next) {
  const { name, email } = req.body;
  const errors = [];

  if (!name || typeof name !== 'string' || name.trim().length < 2) {
    errors.push('Name is required and must be at least 2 characters');
  }

  if (!email || !/^\S+@\S+\.\S+$/.test(email)) {
    errors.push('A valid email is required');
  }

  if (errors.length > 0) {
    return res.status(400).json({ errors });
  }

  next();
}

app.post('/api/users', validateUser, (req, res) => {
  // if we reach here, name and email are valid
  const user = createUser(req.body);
  res.status(201).json(user);
});

Send a bad request:

curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "", "email": "not-an-email"}'
{
  "errors": [
    "Name is required and must be at least 2 characters",
    "A valid email is required"
  ]
}

The route handler never runs. The validation middleware caught the bad input and sent back a 400 before the request got any further.

For production apps, most teams use a validation library like Joi or Zod. They give you a schema-based approach that handles edge cases you do not want to think about manually:

const Joi = require('joi');

const userSchema = Joi.object({
  name: Joi.string().trim().min(2).max(60).required(),
  email: Joi.string().email().required(),
  age: Joi.number().integer().min(16).max(120),
});

function validate(schema) {
  return (req, res, next) => {
    const { error } = schema.validate(req.body, { abortEarly: false });
    if (error) {
      const messages = error.details.map(d => d.message);
      return res.status(400).json({ errors: messages });
    }
    next();
  };
}

app.post('/api/users', validate(userSchema), (req, res) => {
  const user = createUser(req.body);
  res.status(201).json(user);
});

The validate function is a middleware factory. It takes a schema and returns a middleware function. This pattern lets you reuse the same validation logic across different routes with different schemas. You write validate(userSchema) for user routes and validate(orderSchema) for order routes, and the actual validation logic stays in one place.


Error-handling middleware

Regular middleware has three parameters: req, res, next. Error-handling middleware has four: err, req, res, next. That extra first parameter is how Express knows it is an error handler.

// this goes AFTER all your routes
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: err.message || 'Something went wrong',
  });
});

When any middleware or route calls next(err) with an argument, Express skips everything until it finds a middleware with four parameters. That is your error handler.

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      const err = new Error('User not found');
      err.status = 404;
      return next(err); // goes to the error handler
    }
    res.json(user);
  } catch (err) {
    next(err); // database error, goes to the error handler
  }
});

Without the error handler, unhandled errors crash your server or produce an ugly HTML error page that leaks your stack trace to the client. Neither is what you want.

The error handler must be registered after all other app.use() and route definitions. If you put it at the top, it will never catch anything because Express matches error handlers in order, and errors thrown after the handler's position in the code will not reach it.

A pattern I picked up from a more experienced developer: create custom error classes so your error handler can make smarter decisions.

class AppError extends Error {
  constructor(message, status) {
    super(message);
    this.status = status;
  }
}

// in a route
if (!user) {
  throw new AppError('User not found', 404);
}

// in the error handler
app.use((err, req, res, next) => {
  if (err instanceof AppError) {
    return res.status(err.status).json({ error: err.message });
  }
  // unexpected error, log it, send generic message
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

Known errors get their proper status code and message. Unknown errors get logged for debugging but the client only sees a generic message. You do not want to send a raw database error or a stack trace to whoever is calling your API.


Putting it all together

Here is a stripped-down Express app with middleware in the right order. This is the skeleton most Express projects start from.

const express = require('express');
const morgan = require('morgan');
const app = express();

// 1. Built-in middleware (body parsing)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 2. Third-party middleware (logging)
app.use(morgan('dev'));

// 3. Custom middleware (add request timestamp)
app.use((req, res, next) => {
  req.requestTime = new Date().toISOString();
  next();
});

// 4. Routes
app.get('/', (req, res) => {
  res.json({ status: 'running', time: req.requestTime });
});

app.use('/api/users', require('./routes/users'));
app.use('/api/orders', require('./routes/orders'));

// 5. 404 handler (no route matched)
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

// 6. Error handler (must be last, must have 4 parameters)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: err.message || 'Internal server error',
  });
});

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

The order follows a pattern: parsing goes first so req.body is available everywhere, logging goes next so every request gets recorded, custom middleware goes after that, then routes, then the 404 catch-all for unmatched routes, and the error handler goes dead last.

The 404 handler is a regular middleware with no path argument. Since it is registered after all routes, Express only reaches it when no route matched the incoming request. It does not have four parameters, so it is not an error handler. It just sends a 404 response.


Common mistakes I made (so you do not have to)

Forgetting next(): The request hangs and the client eventually times out. No error in the console. If a route is mysteriously not responding, check if a middleware above it forgot to call next().

Calling next() after sending a response: Causes the "headers already sent" error. Use return before res.send() or res.json() in conditional blocks.

Putting middleware after routes: The middleware never runs for those routes because Express already matched and handled the request.

Not using express.json(): req.body is undefined and you blame your frontend, your database, your HTTP client, and possibly the universe before realizing you forgot one line.

Writing async middleware without try/catch: Unhandled promise rejections in Express 4 do not trigger the error handler. They crash the process or get silently swallowed. Wrap async middleware in try/catch and pass errors to next(err). Express 5 fixes this, but most projects are still on 4.

// Express 4: you need this wrapper
app.get('/api/data', async (req, res, next) => {
  try {
    const data = await fetchSomething();
    res.json(data);
  } catch (err) {
    next(err);
  }
});

Middleware is the pattern that holds an Express app together. Once you understand the request pipeline and the role of next(), most Express features make more sense. The concepts you will run into next build directly on this: route grouping with express.Router(), authentication strategies with Passport.js, more sophisticated validation with Zod or Joi, rate limiting with express-rate-limit, and CORS handling with the cors package. All of them are middleware. They follow the same (req, res, next) pattern you just learned.

If something in your Express app is not working and you cannot figure out why, add a console.log to each middleware and check the order. Nine times out of ten, the answer is either a missing next(), a misplaced app.use(), or middleware running in the wrong sequence.


References