How to Use Express Middleware

How to Use Express Middleware Express.js is one of the most popular Node.js frameworks for building web applications and APIs. At the heart of its flexibility and power lies a core concept known as middleware . Whether you’re logging requests, authenticating users, parsing request bodies, or serving static files, Express middleware enables you to modularize and reuse functionality across your appl

Oct 30, 2025 - 13:02
Oct 30, 2025 - 13:02
 0

How to Use Express Middleware

Express.js is one of the most popular Node.js frameworks for building web applications and APIs. At the heart of its flexibility and power lies a core concept known as middleware. Whether you’re logging requests, authenticating users, parsing request bodies, or serving static files, Express middleware enables you to modularize and reuse functionality across your application. Understanding how to use Express middleware effectively is not just a technical skill—it’s a foundational requirement for building scalable, maintainable, and secure web applications.

This comprehensive guide walks you through everything you need to know about Express middleware—from the basics of how it works to advanced patterns, real-world examples, and industry best practices. By the end of this tutorial, you’ll be able to write, chain, and debug middleware functions with confidence, and apply them to solve common development challenges in production environments.

Step-by-Step Guide

What Is Express Middleware?

Express middleware is a function that has access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by the variable next. Middleware functions can:

  • Execute any code
  • Make changes to the request and response objects
  • End the request-response cycle
  • Call the next middleware function in the stack

If the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Failing to call next() will cause the request to hang indefinitely.

Types of Middleware

Express supports five main types of middleware:

  1. Application-level middleware – Bound to the app object using app.use() or app.METHOD()
  2. Router-level middleware – Bound to an instance of express.Router()
  3. Error-handling middleware – Designed to handle errors and has four arguments: (err, req, res, next)
  4. Built-in middleware – Provided by Express, such as express.static() and express.json()
  5. Third-party middleware – Installed via npm, such as morgan, helmet, or cors

Setting Up Your Express Project

Before diving into middleware, ensure you have a working Express application. If you don’t have one yet, create a new project:

mkdir my-express-app

cd my-express-app

npm init -y

npm install express

Create a file named server.js and add the following minimal Express server:

const express = require('express');

const app = express();

const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {

res.send('Hello World!');

});

app.listen(PORT, () => {

console.log(Server running on http://localhost:${PORT});

});

Run the server using:

node server.js

Now that your app is running, you’re ready to add middleware.

Step 1: Using Built-In Middleware

Express provides several built-in middleware functions. The most commonly used are:

  • express.json() – Parses incoming JSON requests
  • express.urlencoded({ extended: true }) – Parses URL-encoded data (form submissions)
  • express.static() – Serves static files (CSS, JS, images)

Add these to your server.js file before any route definitions:

const express = require('express');

const app = express();

const PORT = process.env.PORT || 3000;

// Built-in middleware

app.use(express.json()); // Parse JSON bodies

app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies

app.use(express.static('public')); // Serve static files from 'public' folder

app.get('/', (req, res) => {

res.send('Hello World!');

});

app.listen(PORT, () => {

console.log(Server running on http://localhost:${PORT});

});

Now your app can handle JSON POST requests and serve files from a public directory. Create the folder and add a simple file:

mkdir public

echo "<h1>Welcome to the static page!</h1>" > public/index.html

Visit http://localhost:3000/index.html to see the static file served.

Step 2: Writing Custom Application-Level Middleware

Custom middleware lets you define your own logic that runs for every request (or specific routes). Let’s create a simple logger middleware:

const express = require('express');

const app = express();

const PORT = process.env.PORT || 3000;

// Custom middleware: request logger

const logger = (req, res, next) => {

console.log(${new Date().toISOString()} - ${req.method} ${req.path});

next(); // Pass control to the next middleware

};

// Apply the logger to all routes

app.use(logger);

app.use(express.json());

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

app.get('/', (req, res) => {

res.send('Hello World!');

});

app.listen(PORT, () => {

console.log(Server running on http://localhost:${PORT});

});

Now every time you make a request to your server, the current date and HTTP method/path will be logged to the console.

Step 3: Applying Middleware to Specific Routes

You don’t have to apply middleware globally. You can apply it to specific routes or route groups:

app.get('/api/users', logger, (req, res) => {

res.json({ message: 'Users endpoint' });

});

app.post('/api/login', logger, express.json(), (req, res) => {

const { username, password } = req.body;

if (!username || !password) {

return res.status(400).json({ error: 'Username and password required' });

}

res.json({ message: 'Login successful' });

});

In this example, the logger middleware runs only for /api/users and /api/login routes, not for the root / route. This is useful for performance optimization and fine-grained control.

Step 4: Using Router-Level Middleware

For larger applications, organizing routes into separate routers improves maintainability. You can attach middleware to routers just like you do with the app:

// routes/users.js

const express = require('express');

const router = express.Router();

const authenticate = (req, res, next) => {

const token = req.headers['authorization'];

if (!token) {

return res.status(401).json({ error: 'Access token required' });

}

// Simulate token validation

if (token !== 'secret-token') {

return res.status(403).json({ error: 'Invalid token' });

}

next();

};

router.use(authenticate); // Apply to all routes in this router

router.get('/', (req, res) => {

res.json({ users: ['Alice', 'Bob'] });

});

router.post('/', (req, res) => {

res.status(201).json({ message: 'User created' });

});

module.exports = router;

In your main server.js:

const express = require('express');

const app = express();

const PORT = process.env.PORT || 3000;

const userRoutes = require('./routes/users');

app.use(express.json());

app.use('/api/users', userRoutes); // Mount router at /api/users

app.listen(PORT, () => {

console.log(Server running on http://localhost:${PORT});

});

Now, every request to /api/users must include a valid authorization token. This keeps authentication logic contained within the user routes.

Step 5: Error-Handling Middleware

Error-handling middleware has four parameters: (err, req, res, next). It must be defined after all other middleware and routes. Express will automatically pass errors to this middleware if you call next(err).

// Custom error handler

const errorHandler = (err, req, res, next) => {

console.error(err.stack);

res.status(500).json({

error: 'Something went wrong!',

message: process.env.NODE_ENV === 'development' ? err.message : 'Internal Server Error'

});

};

// Route that throws an error

app.get('/error', (req, res, next) => {

throw new Error('This is a test error');

});

// Apply error handler after all routes

app.use(errorHandler);

app.listen(PORT, () => {

console.log(Server running on http://localhost:${PORT});

});

When you visit /error, the error is caught by the error-handling middleware and returned as a clean JSON response. This prevents your server from crashing and ensures consistent error responses.

Step 6: Async Middleware and Error Handling

One common pitfall is using async functions as middleware without proper error handling. If an async middleware throws an error, it won’t be caught by the default error handler unless you wrap it.

Here’s a safe pattern:

const asyncHandler = fn => (req, res, next) =>

Promise.resolve(fn(req, res, next)).catch(next);

app.get('/async-example', asyncHandler(async (req, res) => {

const data = await someAsyncFunction(); // Might throw

res.json(data);

}));

This wrapper ensures any rejected promise is passed to the error-handling middleware. You can define this utility once and reuse it across your application.

Step 7: Middleware Order Matters

Middleware functions are executed in the order they are defined. This is critical for correct behavior.

Example of incorrect order:

app.use(express.json()); // ✅ Good: JSON parser before routes

app.use('/api', authenticate); // ✅ Good: Auth before API routes

app.get('/api/users', (req, res) => { ... }); // ✅ Route after middleware

// ❌ BAD: Middleware after route

app.get('/api/users', (req, res) => {

res.json({ users: [] });

});

app.use(express.json()); // This will NEVER run for /api/users

Always define middleware before routes that depend on it. For example, if you want to parse JSON before accessing req.body, express.json() must come before any route that reads req.body.

Step 8: Debugging Middleware

To debug middleware execution, add logging or use a tool like debug:

const debug = require('debug')('app:middleware');

const logger = (req, res, next) => {

debug(${req.method} ${req.path});

next();

};

Run your app with:

DEBUG=app:middleware node server.js

This outputs only debug logs related to your middleware, helping you trace execution flow without clutter.

Best Practices

1. Keep Middleware Lightweight

Each middleware function adds processing time. Avoid heavy operations like database queries or external API calls in middleware unless absolutely necessary. If you need data from a database, fetch it in the route handler or use caching.

2. Modularize Middleware

Don’t define all middleware in a single file. Create separate files for:

  • Authentication
  • Logging
  • Rate limiting
  • Validation

Example structure:

middleware/

├── auth.js

├── logger.js

├── validator.js

├── rateLimit.js

└── errorHandler.js

This improves code organization, testability, and reusability.

3. Use Middleware for Cross-Cutting Concerns

Middleware is ideal for concerns that span multiple routes:

  • Request logging
  • Security headers
  • Rate limiting
  • CORS configuration
  • Request validation

By centralizing these in middleware, you avoid code duplication and ensure consistency.

4. Avoid Blocking the Event Loop

Never use synchronous blocking operations like fs.readFileSync() or long-running loops in middleware. Always prefer asynchronous operations with async/await or callbacks.

5. Always Call next() (Except When Ending the Response)

Remember: if you don’t call next() and don’t send a response with res.send(), res.json(), etc., the request will hang. Always ensure one or the other happens.

6. Use Error-Handling Middleware for All Errors

Never let unhandled exceptions crash your server. Always define a global error handler at the end of your middleware stack. Use try/catch or the asyncHandler wrapper for async routes.

7. Test Middleware in Isolation

Write unit tests for your middleware functions. Mock the req, res, and next objects to verify behavior.

// test/logger.test.js

const logger = require('../middleware/logger');

describe('logger middleware', () => {

it('logs request method and path', () => {

const req = { method: 'GET', path: '/' };

const res = {};

const next = jest.fn();

logger(req, res, next);

expect(console.log).toHaveBeenCalledWith(expect.stringContaining('GET /'));

expect(next).toHaveBeenCalled();

});

});

8. Document Your Middleware

Clearly document what each middleware does, what it expects in the request, and what it modifies. Use JSDoc or inline comments:

/**

* Authenticates requests using a Bearer token in the Authorization header.

* @param {Object} req - Express request object

* @param {Object} res - Express response object

* @param {Function} next - Express next middleware function

* @returns {void}

*/

const authenticate = (req, res, next) => {

// ...

};

Tools and Resources

Essential npm Packages for Middleware

These widely-used packages provide powerful middleware out of the box:

  • morgan – HTTP request logger with customizable formats
  • helmet – Secures Express apps by setting various HTTP headers
  • cors – Enables CORS with configurable options
  • express-rate-limit – Rate limits repeated requests to prevent abuse
  • express-validator – Validates and sanitizes request data
  • compression – Compresses response bodies with Gzip or Deflate
  • cookie-parser – Parses cookies attached to the client request

Installation Examples

Install and use these packages in your Express app:

npm install morgan helmet cors express-rate-limit express-validator compression cookie-parser

Then use them in your server:

const morgan = require('morgan');

const helmet = require('helmet');

const cors = require('cors');

const rateLimit = require('express-rate-limit');

const { body, validationResult } = require('express-validator');

const compression = require('compression');

const cookieParser = require('cookie-parser');

app.use(helmet()); // Security headers

app.use(cors()); // Allow cross-origin requests

app.use(compression()); // Reduce response size

app.use(cookieParser()); // Parse cookies

app.use(morgan('combined')); // Log requests

app.use(rateLimit({

windowMs: 15 * 60 * 1000, // 15 minutes

max: 100 // limit each IP to 100 requests per windowMs

}));

Debugging and Profiling Tools

  • Node.js Inspector – Built-in profiler accessible via node --inspect server.js
  • clinic.js – Performance diagnostics for Node.js apps
  • Postman – Test API endpoints and inspect middleware behavior
  • Express Debug – Use DEBUG=express:* node server.js to see internal Express routing

Learning Resources

Real Examples

Example 1: Authentication Middleware with JWT

Here’s a complete example of JWT-based authentication middleware:

// middleware/auth.js

const jwt = require('jsonwebtoken');

const authenticateJWT = (req, res, next) => {

const authHeader = req.headers.authorization;

if (!authHeader) {

return res.status(401).json({ error: 'Access token required' });

}

const token = authHeader.split(' ')[1]; // Bearer <token>

jwt.verify(token, process.env.JWT_SECRET, (err, user) => {

if (err) {

return res.status(403).json({ error: 'Invalid or expired token' });

}

req.user = user; // Attach user info to request

next();

});

};

module.exports = authenticateJWT;

Use it in your route:

// routes/profile.js

const express = require('express');

const router = express.Router();

const authenticateJWT = require('../middleware/auth');

router.get('/', authenticateJWT, (req, res) => {

res.json({ user: req.user });

});

module.exports = router;

Now, only authenticated users can access the profile endpoint.

Example 2: Input Validation Middleware

Use express-validator to validate request data:

// middleware/validateUser.js

const { body } = require('express-validator');

const validateUser = [

body('email').isEmail().withMessage('Valid email required'),

body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),

(req, res, next) => {

const errors = validationResult(req);

if (!errors.isEmpty()) {

return res.status(400).json({ errors: errors.array() });

}

next();

}

];

module.exports = validateUser;

Apply it to a route:

app.post('/register', validateUser, (req, res) => {

// Safe to assume req.body.email and req.body.password are valid

res.json({ message: 'User registered' });

});

Example 3: Rate Limiting for Public APIs

Prevent abuse of public endpoints with rate limiting:

// middleware/rateLimit.js

const rateLimit = require('express-rate-limit');

const apiLimiter = rateLimit({

windowMs: 15 * 60 * 1000, // 15 minutes

max: 100, // limit each IP to 100 requests per windowMs

message: {

error: 'Too many requests from this IP, please try again later.'

},

standardHeaders: true,

legacyHeaders: false,

});

module.exports = apiLimiter;

Apply to public API routes:

app.use('/api/public', apiLimiter);

app.get('/api/public/data', (req, res) => {

res.json({ data: 'public data' });

});

Example 4: Logging Middleware with Request ID

Enhance logging by adding a unique request ID for tracing:

// middleware/requestId.js

const uuid = require('uuid');

const requestId = (req, res, next) => {

req.requestId = uuid.v4();

res.setHeader('X-Request-ID', req.requestId);

next();

};

const logger = (req, res, next) => {

const start = Date.now();

res.on('finish', () => {

const duration = Date.now() - start;

console.log(${req.requestId} - ${req.method} ${req.path} ${res.statusCode} ${duration}ms);

});

next();

};

module.exports = { requestId, logger };

Use in app:

app.use(requestId);

app.use(logger);

Now each request has a traceable ID, invaluable for debugging production issues.

FAQs

What happens if I forget to call next() in middleware?

If you forget to call next() and don’t send a response with res.send(), res.json(), or similar, the request will hang indefinitely. The client will wait forever for a response, and the server will not process any subsequent middleware. Always ensure either next() is called or a response is sent.

Can middleware modify the request or response objects?

Yes. Middleware is designed to modify req and res objects. For example, authentication middleware often attaches req.user, and logging middleware adds request IDs. This is a core feature that enables middleware to pass data between functions.

How is middleware different from route handlers?

Middleware functions do not necessarily end the request-response cycle—they pass control to the next function using next(). Route handlers (like app.get('/', (req, res) => {...})) typically end the cycle by sending a response. Middleware can be reused across multiple routes; route handlers are specific to a path and method.

Can I use middleware in Express without routing?

Yes. Middleware can be used independently of routes. For example, you can use app.use(express.static('public')) to serve static files without defining any routes. Middleware is applied based on the path prefix you provide to app.use().

How do I skip middleware for certain routes?

Apply middleware only to specific paths. For example:

app.use('/api', authMiddleware); // Only applies to /api/*

app.get('/public', (req, res) => { ... }); // No auth required

Alternatively, create a custom middleware that checks the route and calls next() if it should be skipped.

Is Express middleware synchronous or asynchronous?

Express middleware can be either. You can use synchronous functions or async/await functions. However, for async middleware, always wrap them in an error handler or use the asyncHandler pattern to avoid uncaught promise rejections.

How do I test middleware without starting the server?

Mock the req, res, and next objects. For example:

const req = { method: 'GET', path: '/test' };

const res = {

status: jest.fn().mockReturnThis(),

json: jest.fn()

};

const next = jest.fn();

yourMiddleware(req, res, next);

expect(next).toHaveBeenCalled();

Use Jest or Mocha for testing. This allows you to test middleware logic in isolation.

Can I use middleware with WebSocket or Socket.IO?

Express middleware does not apply to WebSocket connections. However, Socket.IO provides its own middleware system using io.use(). You can use Express middleware to authenticate HTTP requests before upgrading to WebSocket, but not within the WebSocket connection itself.

Why is middleware order important?

Middleware executes in the order it’s defined. If you place a route handler before a required middleware (like express.json()), the route will receive an empty req.body. Always define parsing, authentication, and validation middleware before routes that depend on them.

Conclusion

Express middleware is not just a feature—it’s the backbone of scalable, maintainable, and secure Node.js applications. By mastering how to write, chain, and debug middleware, you gain the ability to modularize your application’s logic, enforce consistency across routes, and handle common concerns like authentication, logging, and validation in a clean, reusable way.

This guide has walked you through everything from basic setup to advanced patterns like async error handling, router-level middleware, and real-world implementations. You’ve learned how to leverage built-in and third-party middleware, follow industry best practices, and structure your code for long-term maintainability.

Remember: middleware is most powerful when used intentionally. Don’t overuse it—only apply it where it adds clear value. Keep it lightweight, test it rigorously, and document it thoroughly. With these principles in mind, your Express applications will be robust, efficient, and production-ready.

As you continue building real-world applications, experiment with combining multiple middleware functions, create your own reusable utilities, and contribute to the ecosystem by sharing your middleware packages on npm. The more you practice, the more intuitive Express middleware will become—and the more confident you’ll be in building complex, high-performance web services.