โ€” ยท 7 min read Min read

A pattern for chaining nextjs middlewares

Post

Recently, I've been using extensively middlewares for various purposes in the billiv's public webapp.

These middlewares have been employed for tasks such as authentication, manifest.json enhancement, feature flagging, correlation ID injection, user agent detection, internationalization, and maintenance mode, among others. The middlewares have proven to be incredibly useful in managing and enhancing the functionality of the web app because they run before the request is processed.

Initially, I added all the middleware functionalities to the middleware.ts file. However, as the number of functionalities increased, the file became cluttered and difficult to maintain. It was not an ideal solution, and I was dissatisfied with the structure and organization of the code.

To address this issue, I decided to implement a pattern inspired by Express.js and the chain of responsibility pattern. The idea behind this pattern is to create a chain of middlewares, with each middleware being responsible for a specific task. Each middleware can decide to either stop the chain or call the next middleware in line.

In the user's code, it looks like this:

// middlewares.ts
import { NextResponse } from 'next/server';
 
export const middleware = withI18n(function augmentedMiddleware() {
  // the response will now have a `locale` cookie
  return NextResponse.next();
});

And it can of course be chained with other middlewares, or deal with async code:

// middlewares.ts
import { NextResponse } from 'next/server';
 
export const middleware = withI18n(
  withBotPrevention(
    withAuthenticatedUser(async function augmentedMiddleware(
      request: NextRequest,
    ) {
      await someAsyncCode();
      return NextResponse.next();
    }),
  ),
);

Implementing this pattern greatly improved the maintainability and readability of the code. It also enabled independent testing of each middleware, making the development process more efficient.

The implementation

The implementation of this pattern is relatively straightforward. It involves creating a function that takes a middleware as input and returns another middleware. This allows for chaining of multiple middlewares and handling of asynchronous code. Let's take a look at a couple of examples to better understand the implementation.

First, let's consider a middleware called withMaintenance, which shows a maintenance page based on the edge configuration:

import { get } from '@vercel/edge-config';
 
export function withMaintenance(
  nextMiddleware: (
    request: NextRequest,
  ) => Promise<NextResponse> | NextResponse,
): (request: NextRequest) => Promise<NextResponse> | NextResponse {
  return async function maintenanceMiddleware(
    request: NextRequest,
  ): Promise<NextResponse> | NextResponse {
    // interrupt the chain of middlewares here, and return a redirect if maintenance is on
    if ((await get('maintenance')) === true) {
      return new NextResponse.redirect(new URL('/maintenance', request.url));
    }
    // otherwise we call the next middleware
    return nextMiddleware(request);
  };
}

In this example, if the maintenance mode is enabled (based on the edge configuration), the middleware interrupts the chain and returns a redirect to the maintenance page. Otherwise, it calls the next middleware in the chain.

Next, let's consider a middleware called withLanguageCookie, which adds a language cookie based on the Accept-Language header but continues the flow:

export function withLanguageCookie(
  nextMiddleware: (
    request: NextRequest,
  ) => Promise<NextResponse> | NextResponse,
): (request: NextRequest) => Promise<NextResponse> | NextResponse {
  return async function middlewareWithLanguageCookie(
    request: NextRequest,
  ): Promise<NextResponse> | NextResponse {
    if (!request.cookies.get(I18N_COOKIE_NAME)?.value) {
      const response = await nextMiddleware(request);
      response.cookies.set('language', 'fr'); // TODO: chose the language based on the Accept-Language header
 
      return response;
    }
  };
}

In this example, the withLanguageCookie middleware calls the next middleware, retrieves its response, and appends a language cookie to it if the language cookie is not already set.

How to test it

Testing is fairly easy as each middleware is a function that takes a request and returns a response. We can write tests to ensure the desired functionality of each middleware:

// withMaintenance.test.ts
import { withMaintenance } from './withMaintenance';
describe('withMaintenance', () => {
  test('withMaintenance() redirects to the maintenance page', async () => {
    const request = new NextRequest('http://localhost/hello');
 
    const response = await withMaintenance(() => new NextResponse('content'))(
      request,
    );
    expect(response.headers.location).toBe('http://localhost/maintenance');
  });
});
// withLanguageCookie.test.ts
import { withLanguageCookie } from './withLanguageCookie';
describe('withLanguageCookie', () => {
  test('withLanguageCookie() sets the language cookie', async () => {
    const request = new NextRequest('http://localhost/hello');
 
    const response = await withLanguageCookie(
      () => new NextResponse('content'),
    )(request);
    expect(response.cookies.get('language')).toBe('fr');
  });
});

Composing the middlewares

Having a bunch of middleware quickly becomes ugly as hell:

export const middleware = withMaintenanceMode(
  withCorrelationId(
    withManifestJson(
      withLanguageCookie(
        withSomethingElse(),
        // actual middleware code.....
      ),
    ),
  ),
);

Luckily redux showed us the way with compose so we can write a simple function to compose multiple middlewares into a single middleware.

The final result looks like this:

// middleware.ts
const composedMiddlewares = compose(
  withMaintenanceMode,
  withCorrelationId,
  withManifestJson,
  withLanguageCookie,
  withSomethingElse,
);
 
export const middleware = composedMiddlewares(() => {
  // we made it to the end of the chain !
  return NextResponse.next();
});

The compose function is quite simple (I'm lying, it's not simple at all, but it's not the point here just copy/paste and enjoy) :

type MiddlewareWrapper<Middleware> = (
  wrappedMiddleware: Middleware,
) => Middleware;
 
// At least I tried to make it a bit readable
export const compose = <Middleware>(
  firstMiddlewareWrapper: MiddlewareWrapper<Middleware>,
  ...otherMiddlewareWrappers: MiddlewareWrapper<Middleware>[]
): MiddlewareWrapper<Middleware> =>
  otherMiddlewareWrappers.reduce(
    (accumulatedMiddlewares, nextMiddleware) => (middleware) =>
      accumulatedMiddlewares(nextMiddleware(middleware)),
    firstMiddlewareWrapper,
  );

Conclusion

Dealing with middlewares can get tricky quite fast, especially when you have a lot of different cases to handle.

Using a chain of middlewares helps a lot to keep the code clean and maintainable. By utilizing the pattern described in this blog post, you can create a modular and extensible middleware architecture for your Next.js applications. So go ahead, embrace this pattern, and make the most of the power of middlewares in your projects!

Use and abuse it!

Final Conclusion

Add me on twitter, I WAN FRIENDS ๐Ÿฅน