Middleware is one of those words that sounds way more complicated than it actually is.
The moment people hear it, they picture some advanced, mysterious concept only "real" developers understand. In reality? It's one of the simplest and most useful ideas in Express.js once you see it in action.
Let's break it down in plain, no-jargon English.
So What Actually Is Middleware?
Picture an onion. Not because it's complicated — because it has layers. Every time a request hits your Express server, it can pass through several "layers" before it finally gets a response. Each one of those layers is a middleware function.
More technically: a middleware function is just a regular function that gets access to the request (what the user is asking for) and the response (what you're about to send back). Inside that function, you can:
- Run any code you want
- Change the request or response
- End the request right there and then
- Or pass things along to the next layer
That last option — passing things along — is what makes middleware so powerful. It's like a relay race where each runner (middleware function) does their part and then hands off the baton.
The 5 Types of Middleware (Don't Worry, They're Simple)
Before touching code, it helps to know the different "flavors" of middleware you'll run into:
- Application-level middleware — Attached directly to your Express app. Can run on every request or just specific routes.
- Router-level middleware — Same idea, but scoped to a specific
express.Router()instance instead of the whole app. - Error-handling middleware — Special middleware built specifically to catch and deal with errors.
- Built-in middleware — Comes free with Express, like
express.staticfor serving files (images, CSS, etc.). - Third-party middleware — Middleware other developers built and published, like
body-parser.
Don't stress about memorizing these right now. They'll make much more sense once you see them in action below.
Getting Set Up
Before writing any middleware, make sure Express is installed in your project:
npm install express
Got it installed? Great. Let's write some actual code.
Example 1: Basic Application-Level Middleware
This first example creates middleware that logs a little note every time someone visits your server.
const express = require('express');
const app = express();
// Middleware function
app.use((req, res, next) => {
console.log(`${req.method} request for '${req.url}'`);
next(); // Pass control to the next middleware function
});
app.get('/', (req, res) => {
res.send('Hello, world!');
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
What's happening here, step by step:
- First two lines: Import Express and fire up your app, just like starting any Express project.
app.use(...): This is how you register middleware. Whatever function you put insideapp.use()will run on every single request that comes into your server.console.log(...): Every time someone visits your server, this prints out what type of request it was (GET, POST, etc.) and which URL they hit. Super handy for debugging.next(): This is the most important part to understand. Callingnext()tells Express, "Okay, I'm finished, let the request continue on to wherever it's supposed to go next." If you forget to callnext(), the request just freezes there forever — the user's browser will spin and spin with no response. This is the single most common middleware mistake beginners make.app.get('/', ...): After the middleware does its job and callsnext(), this route finally sends back "Hello, world!" to the browser.app.listen(3000, ...): Starts the server on port 3000.
Think of the middleware here as a security guard checking IDs at the door before letting people into the actual party (your route).
Example 2: Router-Level Middleware
Once basic middleware clicks for you, router-level middleware is just a small step further — it's middleware that only applies to a specific group of routes instead of your entire app.
const express = require('express');
const app = express();
const router = express.Router();
// Router-level middleware
router.use((req, res, next) => {
console.log(`Time: ${Date.now()} - ${req.method} request on router`);
next();
});
router.get('/about', (req, res) => {
res.send('About Page');
});
app.use('/router', router);
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Breaking it down:
express.Router(): This creates a mini, self-contained version of your app — basically a separate bucket you can drop related routes into.router.use(...): Same idea asapp.use(), except this middleware only runs for requests going through this specific router, not your entire application.router.get('/about', ...): A normal route, but it lives inside the router instead of directly onapp.app.use('/router', router): This connects, or "mounts," your router onto the app at the/routerpath. So the/aboutroute above actually becomes accessible at/router/about.
Why does this matter? Because as your app grows, you don't want one giant messy pile of routes and middleware. Router-level middleware lets you group related logic together — like having separate middleware just for your /admin routes versus your /public routes.
Example 3: Error-Handling Middleware
Things go wrong sometimes. A database might fail, a file might not exist, or something unexpected happens. Error-handling middleware is your app's way of catching those problems gracefully instead of crashing.
const express = require('express');
const app = express();
// Application-level middleware causing error
app.get('/error', (req, res, next) => {
const err = new Error('Something went wrong!');
err.status = 500;
next(err); // Pass the error to the error-handling middleware
});
// Error-handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).send(err.message || 'Internal Server Error');
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Here's what's going on:
- The
/errorroute: This is just a pretend example that deliberately creates an error, so you can see how error-handling works. In a real app, this error might come from something like a failed database query instead. next(err): Notice something different here — instead of callingnext()with nothing inside it, we're passing the error object into it. This is a signal to Express: "Hey, skip all the normal middleware, this needs to go straight to the error handler."- The error-handling middleware itself: You can always spot error-handling middleware because it takes four arguments instead of the usual three —
(err, req, res, next). That extraerrparameter is what tells Express "this one handles errors." console.error(err.stack): Logs the full error details to your terminal, which is incredibly useful when you're trying to figure out what broke.res.status(err.status || 500).send(...): Sends a proper error response back to the user, along with the correct status code (or defaults to a generic 500 "Internal Server Error" if none was set).
Think of this like a safety net at a circus. Nobody wants to think about it, but when something falls, it's there to catch it instead of letting the whole show collapse.
Why Should You Actually Care About Middleware?
Because almost everything useful in a real-world Express app happens through middleware:
- Logging who's visiting your site
- Checking if a user is logged in before letting them see a page
- Reading data sent from a form
- Compressing responses to make your site faster
- Catching and handling errors so your app doesn't just crash
Once you understand middleware, you basically understand how Express handles everything, because pretty much every feature in Express — including your own routes — works using this same request-response-next() pattern.