Giancarlo Buomprisco

Giancarlo Buomprisco

·7 min read

Clean API with Next.js Middleware Pipes

Next.js API routes are lean and powerful - but reusing code is not straightforward. Next API Middlewares can help us write better code and more reusable API.

Next.js API routes are fun, simple, and straightforward. However, they're also a little bare-bones.

Next's API routes are great when you're building on top of it, but it requires developers to write much additional code to build functionalities that many take for granted, such as:

  • protecting against unsupported methods
  • gating access to signed-in users
  • any prior set-up that needs to be done before executing the business logic of such route

What is a Middleware?

NB: the concept of middleware in this post is not to be confused with the recently released Next.js Middlewares. Instead, the concept outlined in this article is more akin to ExpressJS's middlewares.

A middleware can be defined as a function that has access to the request and response objects parameters of an API function. Much of our logic has to rely on these two parameters for reading data (such as the body object, headers, cookies) and respond appropriately to the caller of the API.

Much of the logic behind very common checks and actions is often duplicated across API routes and controllers, such as checking if the user is authenticated, if the method being called is correct, validating the user input, and so on. It doesn't have to be so hard.

Being used to working with NestJS, a batteries-included back-end framework, middlewares were a utility I took for granted. Next.js does not come with many opinions, unlike stuff built on top of it such as Blitz.js. Therefore, we'll have to stitch something up.

Why Middlewares?

First of all, why use a middleware at all, as described in thi article? While Next.js's new middleware set out to solve the exact specific issue, there's a small thing to consider: they run at the edge. What does that mean? Running on Cloudflare Workers, a V8 environment which is not NodeJS, means we don't have access to a range of libraries and API that, as of today, still rely on NodeJS.

While many libraries are converging towards an environment-agnostic approach towards Server-Side Javascript, we're nowehere near the state where we can migrate a codebase by simply shuffling code around. In my case, by using Firebase Auth, means I am constrained to running my code within a NodeJS environment.

Just as an example, let's assume we're writing an API handler using Firebase that:

  • initializes an admin app
  • checks that the it's calling the correct method
  • checks a user exists
  • responds to the user's request

Normally, you could write something similar:

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  // we only like POST here
  if (req.method !== 'POST') {
    return res.status(405).end();
  }

  // we can initialize the admin now
  await initializeAdminApp(req);

  const user = await getSignedInUser(req.headers);

  // user not logged in, no good here
  if (!user) {
    return res.status(401).end();
  }

  const data = await makeDatabaseRequest();

  return res.send(data);
}

Now, nothing wrong with what we've written above!

Nevertheless, it's just a tad repetitive if we think our application can have hundreds or more API endpoints. And with repetition, there can undoubtedly be avoidable mistakes.

If you hadn't noticed: the function above does not handle any error, which can happen in at least 3 situations.

Doing the above would considerably increase the boilerplate code needed, making the function above looking way more complex.

A couple of points about the above:

  • we don't need to check we're calling the correct method imperatively
  • we don't need to log the user in imperatively too manually

The average API route can likely have many more "set-up" steps, such as the above: we need a better solution.

Enter Next JS pipes

A middleware is a function that receives two parameters: NextApiRequest and NextApiResponse and returns a function to which we can pass these parameters.

A middleware's job can vary:

  • setting up the libraries (such as Firebase Admin)
  • connecting to the DB
  • hitting a cached result
  • authentication
  • validation of the body, query, method, etc.
  • a lot more use-case you may be aware of

For example, a perfectly valid middleware could be the following function:

export default async function authMiddleware(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  return async function (handler: NextApiHandler) {
    try {
      await initializeFirebaseAdminApp();

      return handler(req, res);
    } catch (e) {
      return internalServerErrorException(res);
    }
  };
}

With the middleware above, we could wrap a Next.js API route so that the code in the middleware will be executed before running the body of the API function.

export default authMiddleware(
    async function myRoute(
      req: NextApiRequest,
      res: NextApiResponse
    ) {
      // admin is now initialized!
      // body of the function goes here
    }
);

Writing middlewares like the above does feel better as we can now encapsulate repetitive logic within smaller functions.

The only downside to this is that when we have multiple middlewares, we need to wrap them one within the other. It can get long.

One other way is to apply a simple pipe function so that the middlewares get executed in the same order, we define them: the very last function, which should be the API route, will be the one sending data back to the caller.

The only exception to this is if we send the headers before that: for example, if we catch an error and want to stop the chain of middlewares, we should not execute them.

Middleware Piping

Okay, let's create a new function called withMiddleware.

This function accepts an undefined number of middlewares, plus the API route, which will send the data back to the caller.

We should place the API route logic as the last function in the pipe.

It's going to look like something similar to the below:

export default withMiddleware(
  withAdmin,
  withMethodsGuard(['POST']),
  withAuthedUser,
  myApiHandler,
);

async function myApiHandler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  const data = await getDataFromDb();

  res.send(data);
}

Is it just I, or this looks a lot better? I hope you agree! :D

Let's see how we can write our chaining function withMiddleware:

import { NextApiRequest, NextApiResponse } from 'next';
type Middleware = (req: NextApiRequest, res: NextApiResponse) => unknown;

/**
 * @name withMiddleware
 * @description combine multiple middleware before handling your API endpoint
 * @param middlewares
 */
export function withMiddleware(...middlewares: Middleware[]) {
  return async function withMiddlewareHandler(
    req: NextApiRequest,
    res: NextApiResponse
  ) {
    async function evaluateHandler(
      middleware: Middleware,
      innerMiddleware?: Maybe<Middleware>
    ) {
      // return early when the request has
      // been ended by a previous middleware
      if (res.headersSent) {
        return;
      }

      if (typeof middleware === 'function') {
        const handler = await middleware(req, res);

        if (typeof handler === 'function') {
          if (innerMiddleware) {
            await handler(innerMiddleware);

            const index = middlewares.indexOf(innerMiddleware);

            // remove inner middleware
            if (index >= 0) {
              middlewares.splice(index, 1);
            }
          } else {
            await handler();
          }
        }
      }
    }

    for (let index = 0; index < middlewares.length; index++) {
      const middleware = middlewares[index];
      const nextMiddleware = middlewares[index + 1];

      await evaluateHandler(middleware, nextMiddleware);
    }
  };
}

We iterate over the functions and execute them one by one using the original req and res parameters, unless one of the functions returns an handler: in which case, the handler gets executed as the callback to the previous middleware, and gets removed from the middlewares list, because we already executed it.

If the property headersSent is true, it means that a previous middleware has already called res.send() or res.end(). Therefore, we skip as it throws an error.

One of the good thing of middlewares is that we can combine them as many times as we like. For example, we know that to use the authentication library, we also need to connect to the admin.

export const withAuthedUser = withMiddleware(
  withAdmin,
  withUser,
);

export const withAdminGuard = withMiddleware(
  withAuthedUser,
  withRoleGuard(['admin']),
);

This code may not be production-grade, but it shows how to increase the reusability and readability of our API routes with a very simple piping function.

The middleware's concept in this article is not to be confused with the concept of Next.js Middleware introduced in Next.js 12, which while they achieve the same thing, they are limited by the fact that they're not running on Node (which is also what makes them so fast).

In our example, we could not use it because Firebase needs to run in a Node environment.

Do you like this pattern? Do let me know if you have any comments or suggestions!


Learn more about
NextNext