Reusable, composable middleware-like wrappers for Next.js API route and middleware.ts
handlers.
-
First install the core library using your favorite package manager:
npm install @nextwrappers/core # npm yarn add @nextwrappers/core # yarn pnpm add @nextwrappers/core # pnpm
OR Install and use pre-made (utility) wrappers in the packages directory:
npm install @nextwrappers/async-local-storage @nextwrappers/matching-paths # npm yarn add @nextwrappers/async-local-storage @nextwrappers/matching-paths # yarn pnpm add @nextwrappers/async-local-storage @nextwrappers/matching-paths # pnpm
-
Next, create a route handler wrapper function with
wrapper
, as follows:App Router
// lib/wrappers/wrapped.ts import { wrapper } from "@nextwrappers/core"; // OR from "@nextwrappers/core/pagesapi" for pages/api directory import { NextRequest } from "next/server"; export const wrapped = wrapper( async (next, request: NextRequest & { isWrapped: boolean }) => { request.isWrapped = true; const response = await next(); response.headers.set("X-Is-Wrapped", "true"); return response; } );
Pages Router
// lib/wrappers/wrapped.ts import { wrapper } from "@nextwrappers/core/pagesapi"; import { NextApiRequest } from "next"; export const wrapped = wrapper( async ( next, request: NextApiRequest & { isWrapped: boolean }, response ) => { request.isWrapped = true; response.headers.set("X-Is-Wrapped", "true"); await next(); } );
-
Finally, wrap the wrapper around an Next.js API handler in a pages/api file:
App Router
// app/api/hello/route.ts import { wrapped } from "lib/wrappers"; import { NextResponse } from "next/server"; export const GET = wrapped((request) => { console.log(request.isWrapped); // => true return NextResponse.json({ message: "Hello from Next.js API!" }); });
Pages Router
// pages/api/hello.ts import { wrapped } from "lib/wrappers"; import { createApiHandler } from "@nextwrappers/core/pagesapi"; const handler = createApiHandler({ wrappers: { GET: wrapped, }, }); handler.GET((request, response) => { console.log(request.isWrapped); // => true response.json({ message: "Hello from Next.js API!", }); }); export default handler;
NB: We are using
createApiHandler
method to create a handler which we can selectively apply wrappers for different request methods, and then we register aGET
handler for theGET
method.
Here are some of the utility methods provided by the core library.
Creates a wrapper around a route/middleware handler that performs some arbitrary piece of logic.
It gives you access to the route handler's request
, an ext
object containing path parameters, and a next
function for executing the wrapped route handler.
Ensure a user has been authenticated with next-auth before continuing with request, then attach current user to the request.
import { getServerSession } from "next-auth/react";
import { Session } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "app/api/auth/[...nextauth]/route.ts";
import { wrapper } from "@nextwrappers/core";
export const authenticated = wrapper(
async (next, request: NextRequest & { user: Session["user"] }) => {
const { user } = await getServerSession(authOptions);
if (!user) {
return NextResponse.json({ message: "Unauthorized" }, { status: 403 });
}
request.user = session.user;
return next();
}
);
We can also create wrappers with arguments, such as this one to ensure a user has the right role to access the API route.
import { wrapper, InferReq } from "@nextwrappers/core";
import { NextResponse } from "next/server";
import { authenticated } from "lib/auth-wrapper";
export const userLevels = {
guest: 0,
user: 1,
admin: 2,
} as const;
export function restrictedTo(level: number) {
return wrapper(async (next, request: InferReq<typeof authenticated>) => {
const userRole = request.user.role;
const userLevel = userLevels[userRole ?? "guest"] ?? userLevels.guest;
if (userLevel < level) {
return NextResponse.json(
{ message: "Unauthorized operation!" },
{ status: 403 }
);
}
return next();
});
}
NB:
InferReq
is a utility type that lets us infer the request type of a wrapper. This is useful when we want to combine multiple wrappers that share the same request type.
Combines multiple wrappers into one to be applied within the same request. The wrappers are executed with the last wrapper being wrapped closest to the route handler.
Building from the example above, we can combine restrictedTo
and authenticated
wrappers to restrict a route to authenticated users with a particular role.
import { stack } from "@nextwrappers/core";
import { authenticated, restrictedTo, userLevels } from "lib/wrappers";
const restrictedToUser = stack(authenticated).with(
restrictedTo(userLevels.user)
);
const restrictedToAdmin = stack(authenticated).with(
restrictedTo(userLevels.admin)
);
Combines wrappers like stack
, except that the wrappers are executed with the first wrapper being wrapped closest to the route handler.
Building from the previous example, we can express the above wrappers with chain
as:
import { chain } from "@nextwrappers/core";
import { authenticated, restrictedTo, userLevels } from "lib/wrappers";
const restrictedToUser = chain(restrictedTo(userLevels.user)).with(
authenticated
);
const restrictedToAdmin = chain(restrictedTo(userLevels.admin)).with(
authenticated
);
In general, stack
is more ergonomic since we add onto the back, versus at the front with chain
.
Combines two wrappers at a time into one. The second wrapper is wrapped closest to the route handler.
Both stack
and chain
are built on top of merge
!
Again, we can express the above wrapper as:
import { merge } from "@nextwrappers/core";
import { authenticated, restrictedTo, userLevels } from "lib/wrappers";
const restrictedToUser = merge(authenticated, restrictedTo(userLevels.user));
const restrictedToAdmin = merge(authenticated, restrictedTo(userLevels.admin));
The
stack
andchain
have a.with()
for endless wrapper combination, butmerge
does not. However, since the result ofmerge
is a wrapper, we can combine multiplemerge
calls to achieve the same effect:
import { merge } from "@nextwrappers/core";
import { w1, w2, w3, w4 } from "lib/wrappers";
const superWrapper = merge(merge(merge(w1, w2), w3), w4);
Creates an API handler that allows us to define wrappers and handlers for each method.
// pages/api/.../[file].ts
import {
publicWrapped,
protectedWrapped,
adminWrapped,
validated,
} from "lib/wrappers";
import { createApiHandler } from "@nextwrappers/core/pagesapi";
import { z } from "zod";
const h = createApiHandler({
wrappers: {
GET: publicWrapped.with(
validated({
query: z.object({
populated: z.union([z.literal("true"), z.literal("false")]),
}),
})
),
PATCH: protectedWrapped.with(
validated({
body: z.object({
name: z.string().min(3).optional(),
}),
})
),
DELETE: adminWrapped,
},
});
h.GET((request, response) => {
console.log(request.query); // => { populated: "true" }
response.json({ message: "Hello from GET!" });
});
h.PATCH((request, response) => {
console.log(request.body); // => { name: "John Doe" }
response.json({ message: "Hello from POST" });
});
h.DELETE((request, response) => {
response.json({ message: "Hello from DELETE" });
});
export default h;
Here are some common ideas and use-cases for @nextwrappers/core
:
We can define a matcher middleware wrapper that selectively applies a middleware logic based on the request path, building on top of Next.js' "Matching Paths" documentation.
This functionality is available as source-code and as a library. See docs here.
We can use a traced
wrapper to trace the request with a unique ID. This is useful for debugging and logging.
This involves using async local storage, which is available as source-code and as a library. See docs here.
For logging and handling errors at the route handler level, we can use a logged
wrapper. This one uses the pino
logger, but you can use any logger you want.
import { wrapper } from "@nextwrappers/core";
import { NextRequest, NextResponse } from "next/server";
import pino from "pino";
const logger = pino();
export const logged = wrapper(
async (next, request: NextRequest, { params }) => {
const start = Date.now();
const { pathname, href } = request.nextUrl;
logger.info(
{
params,
},
`[${request.method}] ${pathname} started`
);
try {
const response = await next();
logger.info(
{
status: response.status,
},
`[${request.method}] ${pathname} completed (${Date.now() - start}ms)`
);
return response;
} catch (e) {
logger.error(
{
reason: (e as Error).message,
},
`[${request.method}] ${pathname} errored (${Date.now() - start}ms)`
);
return NextResponse.json(
{ error: "Request failed", reason: (e as Error).message },
{ status: 500 }
);
}
}
);
We can couple this with the request tracing wrapper to have all logs include the trace ID. To do so, we simply import and use the
getStore
function provided by the AsyncLocalStorage wrapper. See more here.
Usage
// app/api/user/[id]/route.ts
import { logged } from "lib/wrappers";
import { NextRequest, NextResponse } from "next/server";
export const GET = logged((request, { params }) => {
const { id } = params;
return NextResponse.json({ id });
});
We can perform validation of any parts of the request, including the body, query, or even path parameters. We can use the zod
validator for this, and then attach the parsed values to the request object.
import { wrapper } from "@nextwrappers/core";
import { z } from "zod";
import { NextRequest } from "next/server";
export function validated<B extends z.Schema, Q extends z.Schema>(schemas: {
body?: B;
query?: Q;
}) {
return wrapper(
async (
next,
req: NextRequest & { bodyParsed?: z.infer<B>; queryParsed?: z.infer<Q> }
) => {
if (schemas.body) {
const body = await req.json();
req.bodyParsed = schemas.body.parse(body);
}
if (schemas.query) {
const query = getQueryObject(req.url);
req.queryParsed = schemas.query.parse(query);
}
return next();
}
);
}
function getQueryObject(url: string) {
const query: Record<string, any> = {};
new URL(url).searchParams.forEach((value, key) => {
if (Array.isArray(query[key])) {
query[key].push(value);
return;
}
if (query[key]) {
query[key] = [query[key], value];
return;
}
query[key] = value;
});
return query;
}
Usage
//app/api/user/[id]/route.ts
import { NextResponse } from "next/server";
import { User } from "lib/models";
import { dbConnect } from "lib/db";
import { userUpdateSchema } from "lib/schemas";
import {
protectedWrapped,
validated,
restrictedToSpecificUser,
} from "lib/wrappers";
type RouteInfo = {
params: {
id: string;
};
};
const wrappedPost = protectedWrapped
.with(
/* Only allow current user to update their own information */
restrictedToSpecificUser((_, { params }: RouteInfo) => params.id)
)
.with(
/* Ensure sure request body is valid */
validated({
body: userUpdateSchema,
})
);
export const POST = wrappedPost(async (request, { params }: RouteInfo) => {
await dbConnect();
const user = await User.findByIdAndUpdate(params.id, request.bodyParsed, {
new: true,
});
return NextResponse.json({ user });
});
Any wrapper created with this library can readily be used with route handlers provided by other libraries.
With tRPC
Adapted from here
// app/api/trpc/[trpc]/route.ts
import * as trpcNext from "@trpc/server/adapters/next";
import { createContext } from "~server/context";
import { appRouter } from "~/server/api/router";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { logged } from "lib/wrappers";
const handler = logged((req) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext,
})
);
export { handler as GET, handler as POST };
With NextAuth
Adapted from here
// app/api/auth/[...nextauth]/route.ts
import NextAuth, { NextAuthOptions } from "next-auth";
import { logged } from "lib/wrappers";
import GithubProvider from "next-auth/providers/github";
const handler = logged(
NextAuth({
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
],
})
);
export { handler as GET, handler as POST };
With Uploadthing
Adapted from here
/** app/api/uploadthing/route.ts */
import { createNextRouteHandler } from "uploadthing/next";
import { logged } from "lib/wrappers";
import { ourFileRouter } from "./core";
// Get route handlers for Next App Router
const { GET: _GET, POST: _POST } = createNextRouteHandler({
router: ourFileRouter,
});
// Wrap and export routes for Next App Router
export const GET = logged(_GET);
export const POST = logged(_POST);
This project builds on top of patterns from nextjs-handler-middleware
.