Supercharging NextJS with Chainable Middlewares 🔗
When I recently built a NextJS application, I ran into a classic problem - my middleware.ts file was getting out of hand. NextJS’s middleware feature is powerful, but as I needed to add more functionality like authentication, logging, and request tracking, my code started to become messy and hard to test.
The Challenge with NextJS Middleware
The default way of handling middleware in NextJS is through a single middleware.ts file in your project root. It’s great for simple cases, but as your needs grow, you might end up with something like this:
// DON'T DO THIS 🙈
export function middleware(request: NextRequest) {
// Auth check
if (!isAuthenticated(request)) {
return redirect("/login");
}
// Logging
console.log(`${request.method} ${request.url}`);
// Header management
const response = NextResponse.next();
response.headers.set("x-custom-header", "value");
// More middleware logic...
// Even more middleware logic...
// It never ends...
return response;
}
This approach quickly becomes unmaintainable. Testing specific parts becomes nearly impossible, and adding new functionality means navigating through a maze of existing code.
A Better Way: Chainable Middlewares
Instead of adding everything into one middleware.ts lets break them down into small, focused pieces. Here’s the core of it:
// middleware/middleware-chain.ts
import type { NextMiddleware } from "next/server";
export function middlewareChain(
...middlewares: ((next: NextMiddleware) => NextMiddleware)[]
) {
return function chain(final: NextMiddleware): NextMiddleware {
return middlewares.reduceRight(
(next, middleware) => middleware(next),
final
);
};
}
Lets now define our middlewares!
// middleware/with-friday-detector.ts 🍺
import { NextFetchEvent, NextMiddleware, NextRequest } from "next/server";
export function withFridayDetector(next: NextMiddleware) {
return async function middleware(
request: NextRequest,
event: NextFetchEvent
) {
const requestHeaders = new Headers(request.headers);
const isFriday = new Date().getDay() === 5;
requestHeaders.set("x-friday", isFriday ? "yes" : "no");
const modifiedRequest = new NextRequest(request, {
headers: requestHeaders,
});
return next(modifiedRequest, event);
};
}
// middleware/with-banana-counter.ts 🍌
import { NextFetchEvent, NextMiddleware, NextRequest } from "next/server";
import { NextResponse } from "next/server";
export function withBananaCounter(next: NextMiddleware) {
return async function middleware(
request: NextRequest,
event: NextFetchEvent
) {
const response = await next(request, event);
const modifiedResponse = NextResponse.next();
response.headers.forEach((value, key) => {
modifiedResponse.headers.set(key, value);
});
modifiedResponse.headers.set("x-bananas", "🍌");
return modifiedResponse;
};
}
Now my main middleware file is super clean:
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { middlewareChain } from "./middleware/middleware-chain";
import { withBananaCounter } from "./middleware/with-banana-counter";
import { withFridayDetector } from "./middleware/with-friday-detector";
const baseHandler = async (request: NextRequest): Promise<NextResponse> => {
return new Response(undefined, {
status: 200,
headers: new Headers(request.headers),
}) as NextResponse;
};
export const middleware = middlewareChain(
withBananaCounter, // Count bananas 🍌
withFridayDetector // Finally, detect Fridays 🍺
)(baseHandler);
export const config = {
matcher: ["/"],
};
Why This Made My Life Easier
-
Testing Became Simple Instead of testing one giant middleware function, I can test each piece separately
-
Code Organization
- Each middleware lives in its own file
- Clear separation of concerns
- Easy to find and modify specific functionality
-
Reusability I can easily share middlewares between different NextJS projects. For example, my authentication middleware is now a reusable module.