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!