Laying Pipe on Next.js Route Handlers: The Power of Function Composition

TL;DR
Use the next-route-handler-pipe package to compose reusable functions and keep your code clean. 🧽
What’s a Pipe?
A pipe allows you to give it functions — called Pipe Functions — that will be executed in order from left-to-right. That’s what next-route-handler-pipe does, but with some extra magic specific to Next.js Route Handlers.
Pipe Functions run before your handler and have access to the req
object. They can access cookies, headers, body, and even mutate the req
to pass data down the pipe.
Example uses:
- Validate body/query data
- Inspect headers/cookies
- Authenticate a request
- Validate webhook signatures
- Catch errors
How to lay the pipe
Step 1 — Install the package
npm i next-route-handler-pipe
Step 2 — Create a Pipe Function
Creating a Pipe Function is simple. It’s just a function with params: (req, event, next)
. It should return either await next()
or NextResponse
.
export const catchErrors: PipeFunction = async (req, event, next) => {
try {
// This will call the next function in the pipe
return await next();
} catch (error) {
console.error(error);
// This will return a response, and end excution of the pipe
return NextResponse.json({ message: 'Server error!' }, { status: 500 });
}
};
Step 3 — Compose the pipe
Last step is to compose the pipe in your handler file. Simply import the pipe
function and give it your Pipe Functions followed by your handler.
import { pipe } from 'next-route-handler-pipe';
async function handler(req: NextRequest) {
throw new Error("Catch me"); // This will be caught by catchErrors
}
export const POST = pipe(catchErrors, handler);
Validating the request body with Zod
To wrap this up, I want to share my favorite pattern with this library. Easily validate the request body with Zod and append the parsed result to the req
object for easy access in your handler. Not only does this validate the data coming in, but also provides the handler with a typed object. 🙌
The validateBody
Pipe Function
This Pipe Function utilizes the factory pattern to accept a Zod schema, and returns a Pipe Function. PipeFunction
accepts a generic that allows us to inform TypeScript that we’re mutating the req
object.
export const validateBody = (zodSchema: z.ZodSchema): PipeFunction<{ data: any }> => {
return async function (req, event, next) {
// Get the body of the request and parse with Zod
const body = await req.json();
const validation = zodSchema.safeParse(typeof body === 'object' ? body : JSON.parse(body));
// If validation fails we return early, preventing the handler from running.
if (!validation.success) {
console.error('Validation error from validateBody', validation.error);
return NextResponse.json({ message: 'Validation error' }, { status: 400 });
}
// If validation passes we can add the parsed data to the req for easy access in our handler.
else {
req.data = validation.data;
// Always return await next() in your pipe function.
return await next();
}
};
};
Setting up the pipe to use validateBody
Our handler will call the validateBody
function and provide it the Zod schema to parse the body with. If validation fails a 400 will be returned before the handler runs. If it passes, req.data
will contain the parsed data. We can even add a Zod transform if we want to transform the data that is made available to the handler.
const postSchema = z.object({
postId: z.string(),
postTitle: z.string(),
authorName: z.string(),
})
// We can even add a Zod transform if we need
.transform(({postId, postTitle, authorName}) => ({
post_id: postId,
post_title: postTitle,
author_first_name: authorName.split(' ')[0],
author_last_name: authorName.split(' ')[1]
}));
// Infer type from schema
type Post = z.infer<typeof postSchema>;
// Add intersection with `NextRequest` & your inferred type
async function handler(req: NextRequest & { data: Post }) {
// We've validated that this data will always exist at this point
const { post_id, post_title } = req.data;
}
// Notice we're calling the validateBody function - that's because it's a factory that will return a pipe function
export const POST = pipe(catchErrors, validateBody(postSchema), handler);
Conclusion
next-route-handler-pipe is an awesome solution for abstracting composable functions and keeping our code squeaky clean.
If you want to learn more about writing clean code in Next.js and TypeScript make sure to follow me here and on Twitter @Kolbysisk ❤️