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

Kolby Sisk
Udacity Eng & Data
Published in
3 min readDec 18, 2023

--

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 ❤️

--

--

Builder of software with a passion for learning. I specialize in web development and user experience design. www.kolbysisk.com