In this post, I’ll introduce a powerful pattern for managing multiple middleware functions in a Next.js application. This approach allows you to separate your logic into distinct, manageable, and reusable middleware modules, bringing order to what can often become a chaotic part of a growing project.
Motivation
Next.js is a fantastic framework, but its middleware implementation has a notable limitation: you can only have one middleware.ts file. In a small project, this is manageable. But as your application scales, you’ll likely need to handle various cross-cutting concerns like authentication, A/B testing, internationalization (i18n) redirects, analytics, and security headers.
Placing all this logic into a single file quickly leads to a maintenance nightmare. The file becomes bloated, difficult to read, and prone to bugs. This violates the single-responsibility principle and makes it challenging for teams to work on different middleware tasks concurrently without causing conflicts.
The main motivation for this blog post is to demonstrate a clean, scalable solution. By creating a “chaining” function, you can define each piece of middleware logic in its own file and then compose them together in a clear and predictable order. This pattern promotes better code organization, reusability, and maintainability, allowing you to build more robust Next.js applications.
The Solution: A Chaining Utility
To solve this, we can implement a “chaining” function. This approach allows us to define each piece of middleware logic in its own function and then compose them together into a single chain that Next.js can execute.
Here is the core utility that makes this possible. It consists of a type definition for our factories and a function to chain them.
// src/types/middleware-factory.ts
import { NextMiddleware } from 'next/server';
export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware;
// src/utils/chainMiddlewares.ts
import { MiddlewareFactory } from '../types/middleware-factory';
import { NextMiddleware, NextResponse } from 'next/server';
export function chainMiddlewares(
functions: MiddlewareFactory[] = [],
index = 0
): NextMiddleware {
const current = functions[index];
if (current) {
const next = chainMiddlewares(functions, index + 1);
return current(next);
}
return () => NextResponse.next();
}
How It Works
The chainMiddlewares function is a recursive utility that builds a chain of middleware functions. Here’s a breakdown:
- It takes an array of
MiddlewareFactoryfunctions. AMiddlewareFactoryis a higher-order function that accepts aNextMiddlewarefunction (we’ll call itnext) and returns a newNextMiddlewarefunction. - It starts from the first factory (
index = 0). - For each factory, it recursively calls itself to get the next middleware in the chain.
- It then calls the current factory, passing the
nextmiddleware to it. This “wraps” thenextmiddleware inside the current one. - The recursion stops when there are no more factories in the array. At this point, it returns a default middleware that simply calls
NextResponse.next(), terminating the chain.
Creating and Using Middleware Factories
With the chaining utility in place, let’s create two examples: one for handling authentication and another for adding Content Security Policy (CSP) headers.
1. Authentication Middleware
This middleware checks for an authentication token and redirects to a login page if it’s missing.
// src/middlewares/withAuth.ts
import { MiddlewareFactory } from '@/types/middleware-factory';
import { NextRequest, NextResponse } from 'next/server';
export const withAuth: MiddlewareFactory = (next) => {
return async (request: NextRequest, _next) => {
const pathname = request.nextUrl.pathname;
// Skip auth for public paths
if (pathname.startsWith('/login') || pathname.startsWith('/api')) {
return next(request, _next);
}
const token = request.cookies.get('auth_token');
if (!token) {
const url = new URL('/login', request.url);
return NextResponse.redirect(url);
}
// If authenticated, continue to the next middleware
return next(request, _next);
};
};
2. Content Security Policy (CSP) Middleware
This middleware adds security headers to protect your application against cross-site scripting (XSS) and other injection attacks.
// src/middlewares/withCsp.ts
import { MiddlewareFactory } from '@/types/middleware-factory';
import { NextRequest, NextResponse } from 'next/server';
export const withCsp: MiddlewareFactory = (next) => {
return async (request: NextRequest, _next) => {
// First, call the next middleware in the chain
const response = await next(request, _next);
if (response) {
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`;
// Set the CSP header on the response
response.headers.set(
'Content-Security-Policy',
cspHeader.replace(/\s{2,}/g, ' ').trim() // Replace newlines and multiple spaces
);
}
return response;
};
};
Chaining Them Together
Finally, in your main middleware.ts file, you import your chaining function and the individual middleware factories, then compose them.
// src/middleware.ts
import { chainMiddlewares } from './utils/chainMiddlewares';
import { withAuth } from './middlewares/withAuth';
import { withCsp } from './middlewares/withCsp';
const middlewares = [withAuth, withCsp];
export default chainMiddlewares(middlewares);
export const config = {
// Match all request paths except for the ones starting with:
// - _next/static (static files)
// - _next/image (image optimization files)
// - favicon.ico (favicon file)
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
The order in the middlewares array matters. In this example, withAuth will run first. If it decides to redirect, withCsp will never be executed. The CSP headers will only be applied to responses that make it through the entire chain.
Final Considerations
This pattern provides a clean and scalable way to manage your Next.js middlewares. The key benefits are:
- Separation of Concerns: Each middleware has a single responsibility and lives in its own file.
- Reusability: Individual middleware can be easily reused across different projects.
- Maintainability: Code is easier to read, test, and debug.
- Composability: The order of execution is clear and easy to change by reordering the array.
By adopting this simple yet powerful pattern, you can keep your Next.js projects organized and robust, no matter how complex your middleware logic becomes.