Express-Like Middleware in NextJS
The NextJS framework is powerful and feature rich. Not only does it enable fast development and iteration, but it has great support and community around it. For those Node developers who are used to express-like frameworks, one of the most common complaints I hear about the framework is the lack of support for individualized route middleware.
Though this can be achieved by using route matches in your base middleware file, this has always felt a little clunky to me. I like being able to define a route, and define a few methods that can run as middleware on it.
An Express-like Approach
What I want to be able to do is to define my route and define functions that run before the route execution. These functions could do a few things:
- Authenticate users
- Authorize users based on roles or some permission
- Attach data to a request
- Etc
Within NextJS (v14), you can define an API route by putting a route.ts
file within a folder in your app directory. This route.ts
file can support the basic HTTP methods:
- GET
- PUT
- POST
- DELETE
- PATCH
And you would see it used like this:
export async function GET(request) {
// youlogic here
}
export async function POST(request, { params }) {
// Your logic here
}
The goal is to be able to run methods in a generic way before these route handlers are hit. There are a few steps to be able to do this:
- Define a generic route handler that will handle every request and allow for orchestration of middleware.
- Define middleware functions.
- Reference both in your route handler.
Step 1: Generic Route Handler, or your Middleware Handler
In my NextJS applications, I create a folder named “middleware” and the first file that is created is called handle-request.ts
. The contents of this file are as follows:
export interface MiddlewareResponse {
pass: boolean
response?: NextResponse
data?: any
}
export async function handleRequest(
request,
payload,
callback,
middleware: Function[] = []
) {
for (const middlewareFunction of middleware) {
const result: MiddlewareResponse = await middlewareFunction(
request,
payload
);
if (result.pass === false) {
return result.response;
} else if (result.data) {
if (!request.data) {
request.data = {};
}
try {
// NOTE: If there are multiple middlewares that need data, this would overwrite the data from the previous middleware.
request.data = {
...request.data,
...result.data,
};
} catch (e) {
console.error(e);
}
}
}
return callback(request, payload, prisma);
}
This function does a few things:
- It processes the array of
middleware
functions passed to it. - For each one, if it passes it attaches the data fetched from the middleware to the request and moves to the next function. If it fails, it returns the failure response on the middleware function.
- If all pass, it calls the callback passed to it, which is your route handler.
In practice, this method is used like this in a route handler:
import { handleRequest } from "@/middleware/handle-request";
export async function GET(request, extra) {
return handleRequest(request, extra, handleGet, []);
}
export async function PUT(request, extra) {
return handleRequest(request, extra, handlePut, []);
}
async function handleGet(request, { params }) {
// Route handler logic
}
async function handlePut(request, { params }) {
// Route handler logic
}
This may look a little funky, but ultimately whatever is returned from your route handler will be returned by the function, provided the middleware passes.
But what do we put in the last parameter of our handleRequest
function? Let’s look at a basic example of an auth
middleware.
Step 2: Define Middleware
In my middleware folder, I’ll start defining separate files for each middleware I want to support. One middleware per file, just to keep things clean. If you’re using NextAuth and want to support basic auth, you could have an auth.ts
file that looks like this:
import { NextResponse } from 'next/server'
import { MiddlewareResponse } from './handle-request'
import { getServerSession } from "next-auth";
export async function auth(request, payload): Promise<MiddlewareResponse> {
const session = await getServerSession(authOptions);
const userId = session?.user.id;
if (!userId) {
return {
pass: false,
response: NextResponse.json(
{ error: 'Unauthenticated' },
{ status: 401 }
),
}
}
const user = // Your application logic to look up user by ID.
if (!user) {
// If there is a session ID,
// but not a user associated with it, something weird happened.
return {
pass: false,
response: NextResponse.json(
{ error: 'An error occurred' },
{ status: 500 }
),
}
}
return {
pass: true,
data: { user },
}
}
This is pretty basic and gets the job done:
- It checks to see if there’s a NextAuth session.
- If there is, it gets the user ID from the session.
- It looks up the user by the session ID.
- It attaches that to the request.
Step 3: Reference it in your route handler
You can use this middleware in your routes now:
import { handleRequest } from "@/middleware/handle-request";
import { auth } from "@/middleware/auth";
export async function GET(request, extra) {
return handleRequest(request, extra, handleGet, [auth]);
}
export async function PUT(request, extra) {
return handleRequest(request, extra, handlePut, [auth]);
}
async function handleGet(request, { params }) {
// Route handler logic
}
async function handlePut(request, { params }) {
// Route handler logic
}
And your routes are now protected. Your route won’t even get called if that auth function fails.
What if I want multiple middleware functions to run in order?
Good question, that’s pretty easy to accommodate. Let’s define a new middleware to check if someone has a specific role.
isAdminMiddleware
import { NextResponse } from "next/server";
import { MiddlewareResponse } from "./handle-request";
export async function isAdminUser(
request,
payload
): Promise<MiddlewareResponse> {
const { user } = request.data;
// We'll assume there's a "role" parameter on the user object.
if (user.role === "admin") {
return {
pass: true,
data: {
user,
},
};
}
return {
pass: false,
response: NextResponse.json({ message: "Forbidden" }, { status: 403 }),
};
}
Remember, we attach the user
object to the request in the auth middleware, so now we can use it on subsequent requests. In my apps, by convention auth
is always the first middleware to process, so user
is always available on requests to subsequent processors.
Now if you want to add both middleware functions to your handler:
Route Handler:
import { handleRequest } from "@/middleware/handle-request";
import { auth } from "@/middleware/auth";
import { isAdminUser } from "@/middleware/isAdminUser";
export async function GET(request, extra) {
return handleRequest(request, extra, handleGet, [auth, isAdminUser]);
}
export async function PUT(request, extra) {
return handleRequest(request, extra, handlePut, [auth, isAdminUser]);
}
async function handleGet(request, { params }) {
// Route handler logic
}
async function handlePut(request, { params }) {
// Route handler logic
}
And they will run sequentially.
What if I want a dynamic input to my middleware?
Another good question. This may be an example where you want to be able to check for a specific permission on a route, without having to have a middleware method per permission.
In this case, all you have to remember is that, similarly to expressJS where a middleware just needs a function so all you need is a function that returns a function. Let’s take an example:
hasPermission
Middleware
import { NextResponse } from "next/server";
import { MiddlewareResponse } from "./handle-request";
export function hasPermission(
permission
): (request, payload) => Promise<MiddlewareResponse> {
return async function (request, payload): Promise<MiddlewareResponse> {
const { user } = request.data;
// In this case, we're assuming that the user has a "permissions" array
// that would have all permissions from the user. This would be set
// when the user is fetched in the auth middleware
const hasPermission = user.permissions.some(
(p) => p.name === permission
);
if (!hasPermission) {
return {
pass: false,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
};
}
return {
pass: true,
data: { user },
};
};
}
Notice a couple of things:
- This middleware is returning a function.
- That function references the permission parameter so it can dynamically check what is returned.
You would use it like this:
Route Handler
import { handleRequest } from "@/middleware/handle-request";
import { auth } from "@/middleware/auth";
import { isAdminUser } from "@/middleware/hasPermission";
export async function GET(request, extra) {
return handleRequest(request, extra, handleGet, [
auth,
hasPermission('manageUsers')
]);
}
export async function PUT(request, extra) {
return handleRequest(request, extra, handlePut, [
auth,
hasPermission('manageUsers')
]);
}
async function handleGet(request, { params }) {
// Route handler logic
}
async function handlePut(request, { params }) {
// Route handler logic
}
Summary
And there you have it. I’m sure there are ways to do this better, but this is a pretty straightforward way of implementing middleware in a NextJS application without having to work around the framework. This pattern would extend to other frameworks as well.
Thoughts on how to improve it? Share below!