nextjs-handler-middleware
v1.1.0
Published
A simple Next.js API middleware solution! This library was inspired by my desire to learn how to make one with the goal of strong type inference and a modular API.
Downloads
6
Maintainers
Readme
Next.js Handler Middleware
A simple Next.js API middleware solution! This library was inspired by my desire to learn how to make one with the goal of strong type inference and a modular API.
Getting Started 🚀
First install the library using your favorite package manager:
Using NPM
npm install nextjs-handler-middleware
Using Yarn
yarn add nextjs-handler-middleware
Next, define a middleware function with
createMiddleware
, as follows:// lib/path/to/your/middleware/my-middleware.ts import { createMiddleware } from "nextjs-handler-middleware" export const myMiddleware = createMiddleware<{ requestId: string }>((req, res, next) => { // Do something magical...(e.g connect to your database, add a tracer id to the request, etc.) // Attach any extra properties you want to the request req.requestId = '...'; // Execute the request await next(); // Do something after executing the request...(e.g log request duration, emit some analytics, etc.) })
Finally, wrap the middleware around an Next.js API handler in a pages/api file:
// pages/api/hello.ts import { myMiddleware } from "lib/path/to/your/middleware/my-middleware.ts" export default myMiddleware(function handler(req, res) { // Access properties provided by the middleware console.log(req.requestId) // Respond to the request! res.status(200).json({ message: "Hello from Next.js API!" }) })
Middleware Features 🧱
createMiddleware
This is the main offering, which supports middleware creation for Next.js API handlers. Define a middleware that performs any arbitrary piece of logic. It provides the handler's req
and res
, and a next
function for executing the wrapped handler.
With the res
object, you can respond to requests early, and with the req
object, you can attach extra properties to be used by the handler or by subsequent middleware.
Example 1: authMiddleware
: Ensure a user has been authenticated with next-auth before continuing with request, then attach current user to the request.
import { getSession } from "next-auth/react";
import { Session } from "next-auth";
type RequestParams = { user: Session["user"] }
type ResponseBody = { message: string, code: "UNAUTHORIZED" }
const authMiddleware = createMiddleware<RequestParams, ResponseBody>(
async (req, res, next) => {
const user = getSession({ req })
.then((session) => session.user)
.catch((error) => {
console.log(error);
});
if (!user) {
res.status(403).send({ message: "Unauthorized!" });
return;
}
req.user = session.user;
next();
}
);
Example 2: restrictedRoleMiddleware
: Ensure that a user has the right role to access the API route.
import { getSession } from "next-auth/react";
import { User } from "lib/types";
const ROLES = {
guest: "guest",
user: "user",
admin: "admin",
superAdmin: "superAdmin",
} as const;
type Role = typeof ROLES[keyof typeof ROLES];
const ROLE_LEVELS: Record<Role, number> = {
guest: 0,
user: 1,
admin: 2,
superAdmin: 3,
};
type RequestParams = {};
type ResponseBody = { message: string };
type RequestDeps = { user?: User };
const restrictedRoleMiddleware = <R extends Role>(role: R) =>
createMiddleware<RequestParams, ResponseBody, RequestDeps>(
async (req, res, next) => {
const currentUserLevel = ROLE_LEVELS[req.user.role ?? ROLES.guest];
const requiredLevel = ROLE_LEVELS[role];
if (currentUserLevel < requiredLevel) {
res.status(403).send({ message: "Unauthorized operation!" });
return;
}
next();
}
);
stackMiddleware
Often times, we want combine and execute multiple middleware within the same request. For this, we can use any of stackMiddleware
, chainMiddleware
or mergeMiddleware
to combine multiple middleware together.
Building from the example above, we can see that the restrictedRoleMiddleware
depends on the authMiddleware
which sets the current user of the request as a request parameter.
We can combine the above middleware using stackMiddleware
for example in the following way:
Example: Restricted Middleware per User Role
import { stackMiddleware } from "nextjs-handler-middleware"
import { authMiddleware, restrictedRoleMiddleware } from "lib/path/to/your/middleware"
const userRestrictedMiddleware = stackMiddleware(authMiddleware).add(restrictedRoleMiddleware("user"));
const adminRestrictedMiddleware = stackMiddleware(authMiddleware).add(restrictedRoleMiddleware("admin"));
const superAdminRestrictedMiddleware = stackMiddleware(authMiddleware).add(restrictedRoleMiddleware("superAdmin"));
Now, we can apply these middleware to different handler routes as necessary!
chainMiddleware
As above, chainMiddleware
is also is used to combine middleware. In fact it has exactly the same usage pattern as stackMiddleware
, except that the middleware are executed in the opposite order.
In other words, the definitions above are equivalent to:
import { chainMiddleware } from "nextjs-handler-middleware"
import { authMiddleware, restrictedRoleMiddleware } from "lib/path/to/your/middleware"
const userRestrictedMiddleware = chainMiddleware(restrictedRoleMiddleware("user")).add(authMiddleware);
const adminRestrictedMiddleware = chainMiddleware(restrictedRoleMiddleware("admin")).add(authMiddleware);
const superAdminRestrictedMiddleware = chainMiddleware(restrictedRoleMiddleware("admin")).add(authMiddleware);
In general, stackMiddleware
is more erognomic - we add onto the back, versus at the front with chainMiddleware
.
mergeMiddleware
This is another way to combine multiple middleware. It takes in two middleware and combines them into one. Both stackMiddleware
and chainMiddleware
are built on top of mergeMiddleware
, so you may not need to use mergeMiddleware
directly!
Again, we can express the above middleware as:
import { chainMiddleware } from "nextjs-handler-middleware"
import { authMiddleware, restrictedRoleMiddleware } from "lib/path/to/your/middleware"
const userRestrictedMiddleware = mergeMiddleware(authMiddleware, restrictedRoleMiddleware("user"));
const adminRestrictedMiddleware = mergeMiddleware(authMiddleware, restrictedRoleMiddleware("admin"));
const superAdminRestrictedMiddleware = mergeMiddleware(authMiddleware, restrictedRoleMiddleware("superAdmin"));
NB: Unlike stackMiddleware
and chainMiddleware
, mergeMiddleware
does not have a .add()
function for extending it. Though, you could extend it as follows if you wanted to:
import { mergeMiddleware } from "nextjs-handler-middleware"
import { m1, m2, m3, m4 } from "lib/path/to/your/middleware"
const mySuperMergedMiddleware = mergeMiddleware(mergeMiddleware(mergeMiddleware(m1, m2), m3), m4); // ...ad infinitum
createHandler
This is the last and newly added package functions! Sometimes, you may want to execute certain middleware for only certain request methods. When this is the case, createHandler
provides a simple way to express that functionality:
**Example: Only Users can POST, Only Admins can Delete, Everyone can Read
// pages/api/hello.ts
import { userRestrictedMiddleware, adminRestrictedMiddleware } from "lib/path/to/your/middleware";
const handler = createHandler({
middleware: {
post: userRestrictedMiddleware,
delete: adminRestrictedMiddleware,
},
});
handler.get((req, res: NextApiResponse<{ message: string }>) => {
res.status(200).send({ message: "Hello Everyone!" });
});
handler.post((req, res: NextApiResponse<{ message: string; user?: User }>) => {
res.status(200).send({ message: "Hello user!", user: req.user });
});
handler.delete(
(req, res: NextApiResponse<{ message: string; user?: User }>) => {
res.status(200).send({ message: "Hello admin!", user: req.user });
}
);
export default handler;
Ideas and Use-Cases 💡
Request Logging
import { NextApiRequest, NextApiResponse } from "next";
import { createMiddleware } from "../../../../dist";
export const loggingMiddleware = createMiddleware(
async (req, res: NextApiResponse<{ message: string }>, next) => {
const start = Date.now();
console.log(`[${req.method}] ${req.url} started`);
try {
await next();
console.log(
`[${req.method}] ${req.url} completed (${Date.now() - start}ms)`
);
} catch (e) {
console.error(
`[${req.method}] ${req.url} errored (${Date.now() - start}ms)`,
e
);
res.status(500).send({ message: "Request failed" });
}
}
);
Database Connections (Mongoose)
import { NextApiRequest, NextApiResponse } from "next";
import { createMiddleware } from "nextjs-handler-middleware";
import { dbConnect } from "lib/dbConnect"; // Source: https://github.com/vercel/next.js/blob/canary/examples/with-mongodb-mongoose/lib/dbConnect.js
export const dbConnectMiddleware = createMiddleware(
async (req, res: NextApiResponse<{ message: string }>, next) => {
await dbConnect();
next();
}
);
Request Body Validation (with Zod)
import { createMiddleware } from "nextjs-handler-middleware";
import { z } from "zod";
export function bodyValidatorMiddleware<S extends z.Schema>(schema: S) {
return createMiddleware<{ body: z.infer<S> }>((req, res, next) => {
const parsed = schema.safeParse(req.body);
if (parsed.success) {
req.body = parsed.data;
next();
} else {
res.status(400).json({
message: `Invalid request body`,
code: "BAD_FORMAT",
});
}
});
}
Caveat: For this, we only want to apply it to POST/PATCH/PUT requests where we expect to receive a request body. Here, we can leverage createHandler
to selectively apply the middleware.
//pages/api/user/[id].ts
import { createHandler, stackMiddleware } from "nextjs-auth-middleware";
import { userRestrictedMiddleware } from "lib/path/to/your/middleware";
import { z } from "zod";
const userSchema = z.object({
email: z.string().email(),
username: z.string().min(4),
age: z.number().positive(),
});
const userUpdateSchema = userSchema.partial();
const baseMiddleware = stackMiddleware(loggerMiddleware).add(dbConnectMiddleware).add(authMiddleware)
const handler = createHandler({
middleware: {
default: baseMiddleware,
post: baseMiddleware.add(bodyValidatorMiddleware(userUpdateSchema)),
},
});
handler.post(async (req, res) => {
const updatedUser = await updateUserById(req.query.id, req.body);
// req.body: { email: ..., username: ..., age: ...}
res.status(200).json({
message: `User updated!`,
updatedUser
});
});
handler.get(async (req, res) => {
const user = await getUserById(req.query.id);
res.status(200).json({
message: "Hello World",
});
});
export default handler;
With Other Next.js 3rd-party Handlers (e.g TRPC, NextAuth)
TRPC
// pages/api/trpc/[trpc].ts
import * as trpcNext from '@trpc/server/adapters/next';
import { loggerMiddleware } from 'lib';
import { createContext } from 'server/context';
import { appRouter } from '../../../server/routers/_app';
export default loggerMiddleware(
trpcNext.createNextApiHandler({
router: appRouter,
createContext,
})
);
Next-Auth/Auth.js
// pages/api/auth/[...nextauth].ts
import NextAuth, { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { JwtPayload } from 'lib/next-auth';
import { loggerMiddleware } from 'lib';
import NextAuth from 'next-auth';
import GithubProvider from 'next-auth/providers/github';
export default loggerMiddleware(
NextAuth({
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
],
})
);
Notes on Types 📝
Merge-Left Request Parameters
When two middleware, A and B are combined, the request type of B is left-merged into the request type of A. In other words, all types in B add to or override the types in A.
Here is an example of "merge-left":
type A = { body: any; foo: string };
type B = { body: { name: string }; bar: string };
type C = MergeLeft<A, B>;
// ^? { body: { name: string }, foo: string, bar: string }
type D = A & B
// ^? { body: any, foo: string, bar: string }
Using "merge-left" over the default intersection operator (&
) is what preserves the inferred types of the request body validator above when it is combined with other middleware.
With "merge-left", the default 'any' type of Next.js request body
is overridden by the type of body
inferred from the zod validator by the request body validator.
Ideally, instead of a merge-left, a "merge-specific", which either merges left, or chooses the most specific of the types involved in the merge would be ideal. In the example above, swapping the order of the merge in the merge-left causes us to loose the specificity of the body type:
type A = { body: any; foo: string };
type B = { body: { name: string }; bar: string };
type C = MergeLeft<B, A>;
// ^? { body: any, foo: string, bar: string }
I am stilling exploring a solution for a "specific" merge, which should solve the above problem and make the type-inference more resilient. For now, middleware that define the types of Next.js request fields which are (
any
orunknown
) such asbody
, must be the last middleware in the stack for its specific types to be chosen and make its way down to the wrapped handler via inference. I would love to see a contribution that addresses this!
Optional Request Parameters
Middleware may attach typed parameters to a request. By default, types for middleware request parameters
added via createMiddleware
's generics are made optional.
I made this decision as a safety net to gaurd against not attaching the specified properties to the request.
For example consider the following where we do not set the emoji
parameter, even though we said we would in the type:
type RequestParams = { emoji: string }
const middleware = createMiddleware<RequestParams>(
async (req, res, next) => {
await next();
});
When this middleware is used, the resulting type of emoji
in the handler is made optional.
const handler = middleware(async (req, res) => {
res.status(200).send({ message: `hello ${req.emoji.toString()}` }); // req.emoji is possibly undefined
});
If you find this troublesome, you can always fallback to explicitly defining the request parameter type so that it is not optional when used in the handler:
const middleware = createMiddleware(async (req: NextApiRequest & { emoji: string }, res, next) => {
req.emoji = "👋";
await next();
});
In the future, I would love to explore extending something such as eslint, or the typescript compiler type-checker to automatically check that all middleware request parameter extensions are set in the middleware as a way to prevent the possibility of such bugs. I would again love to see a contribution that addresses this!