Melvynx/next-zod-route(opens in a new tab)
Language: TypeScript
Create zod-safe route for your Next.js App Directory app
- stars
- 72
- watchers
- 4
- forks
- 11
- open issues
- 6
- Last updated
- 09/13/2025
Language: TypeScript
Create zod-safe route for your Next.js App Directory app
A fork from next-safe-route that uses zod for schema validation.
next-zod-route is a utility library for Next.js that provides type-safety and schema validation for Route Handlers/API Routes.
npm install next-zod-route zod
Or using your preferred package manager:
pnpm add next-zod-route zod
yarn add next-zod-route zod
// app/api/hello/route.ts import { createZodRoute } from 'next-zod-route'; import { z } from 'zod'; const paramsSchema = z.object({ id: z.string(), }); const querySchema = z.object({ search: z.string().optional(), }); const bodySchema = z.object({ field: z.string(), }); export const GET = createZodRoute() .params(paramsSchema) .query(querySchema) .handler((request, context) => { const { id } = context.params; const { search } = context.query; return { id, search, permission, role }; }); export const POST = createZodRoute() .params(paramsSchema) .query(querySchema) .body(bodySchema) .handler((request, context) => { // Next.js 15 use promise, but with .params we already unwrap the promise for you const { id } = context.params; const { search } = context.query; const { field } = context.body; // Custom status return NextResponse.json({ id, search, field }), { status: 400 }; });
To define a route handler in Next.js:
createZodRoute and zod.createZodRoute() to create a route handler, chaining params, query, body, and defineMetadata methods.context.next-zod-route supports multiple request body formats out of the box:
application/x-www-form-urlencoded data.multipart/form-data, enabling file uploads and complex form data parsing.The library automatically detects the content type and parses the body accordingly. For GET and DELETE requests, body parsing is skipped.
You can return responses in two ways:
return NextResponse.json({ data: 'value' }, { status: 200 });
return { data: 'value' };
You can create a reusable client in a file, I recommend /src/lib/route.ts with the following content:
import { createZodRoute } from 'next-zod-route'; const route = createZodRoute(); // Create other re-usable route const authRoute = route.use(...)
Metadata enable you to add static parameters to the route, for example to give permissions list to our application.
One powerful use case for metadata is defining required permissions for routes and checking them in middleware. This allows you to:
Here's how to implement permission-based authorization:
import { type MiddlewareFunction } from 'next-zod-route'; // Define a schema for permissions metadata const permissionsMetadataSchema = z.object({ requiredPermissions: z.array(z.string()).optional(), }); // Create a middleware that checks permissions const permissionCheckMiddleware: MiddlewareFunction = async ({ next, metadata, request }) => { // Get user permissions from auth header, token, or session const userPermissions = getUserPermissions(request); // If no required permissions in metadata, allow access if (!metadata?.requiredPermissions || metadata.requiredPermissions.length === 0) { return next({ context: { authorized: true } }); } // Check if user has all required permissions const hasAllPermissions = metadata.requiredPermissions.every((permission) => userPermissions.includes(permission)); if (!hasAllPermissions) { // Short-circuit with 403 Forbidden response return new Response( JSON.stringify({ error: 'Forbidden', message: 'You do not have the required permissions', }), { status: 403, headers: { 'Content-Type': 'application/json' }, }, ); } // Continue with authorized context return next({ context: { authorized: true } }); }; // Use in your route handlers export const GET = createZodRoute() .defineMetadata(permissionsMetadataSchema) .use(permissionCheckMiddleware) .metadata({ requiredPermissions: ['read:users'] }) .handler((request, context) => { // Only executed if user has 'read:users' permission return Response.json({ data: 'Protected data' }); }); export const POST = createZodRoute() .defineMetadata(permissionsMetadataSchema) .use(permissionCheckMiddleware) .metadata({ requiredPermissions: ['write:users'] }) .handler((request, context) => { // Only executed if user has 'write:users' permission return Response.json({ success: true }); }); export const DELETE = createZodRoute() .defineMetadata(permissionsMetadataSchema) .use(permissionCheckMiddleware) .metadata({ requiredPermissions: ['admin:users'] }) .handler((request, context) => { // Only executed if user has 'admin:users' permission return Response.json({ success: true }); });
This pattern allows you to:
You can add middleware to your route handler with the use method. Middleware functions can add data to the context that will be available in your handler.
import { type MiddlewareFunction, createZodRoute } from 'next-zod-route'; const loggingMiddleware: MiddlewareFunction = async ({ next }) => { console.log('Before handler'); const startTime = performance.now(); // next() returns a Promise<Response> const response = await next(); const endTime = performance.now() - startTime; console.log(`After handler - took ${Math.round(endTime)}ms`); return response; }; const authMiddleware: MiddlewareFunction = async ({ request, metadata, next }) => { try { // Get the token from the request headers const token = request.headers.get('authorization')?.split(' ')[1]; // You can access metadata in middleware if (metadata?.role !== 'admin') { throw new Error('Unauthorized'); } // Validate the token and get the user const user = await validateToken(token); // Add context & continue chain // next() accepts an optional object with a context property const response = await next({ context: { user }, // This context will be merged with existing context }); // You can modify the response after the handler return new Response(response.body, { status: response.status, headers: { ...Object.fromEntries(response.headers.entries()), 'X-User-Id': user.id, }, }); } catch (error) { // Errors in middleware are caught and handled by the error handler throw error; } }; const permissionsMiddleware: MiddlewareFunction = async ({ metadata, next }) => { // Metadata are optional and type-safe const response = await next({ context: { permissions: metadata?.permissions ?? ['read'] }, }); return response; }; export const GET = createZodRoute() .defineMetadata( z.object({ role: z.enum(['admin', 'user']), permissions: z.array(z.string()).optional(), }), ) .use(loggingMiddleware) .use(authMiddleware) .use(permissionsMiddleware) .handler((request, context) => { // Access middleware data from context.data const { user, permissions } = context.data; // Access metadata from context.metadata const { role } = context.metadata!; return Response.json({ user, permissions, role }); });
Middleware functions receive:
request: The request objectcontext: The context object with data from previous middlewaresmetadata: The validated metadata object (optional)next: Function to continue the chain and add contextThe middleware can:
import { type MiddlewareFunction } from 'next-zod-route'; const timingMiddleware: MiddlewareFunction = async ({ next }) => { console.log('Starting request...'); const start = performance.now(); const response = await next(); const duration = performance.now() - start; console.log(`Request took ${duration}ms`); return response; };
import { type MiddlewareFunction } from 'next-zod-route'; const headerMiddleware: MiddlewareFunction = async ({ next }) => { const response = await next(); return new Response(response.body, { status: response.status, headers: { ...Object.fromEntries(response.headers.entries()), 'X-Custom': 'value', }, }); };
import { type MiddlewareFunction } from 'next-zod-route'; const middleware1: MiddlewareFunction = async ({ next }) => { const response = await next({ context: { value1: 'first' }, }); return response; }; const middleware2: MiddlewareFunction = async ({ context, next }) => { // Access previous context console.log(context.value1); // 'first' const response = await next({ context: { value2: 'second' }, }); return response; };
import { type MiddlewareFunction } from 'next-zod-route'; const authMiddleware: MiddlewareFunction = async ({ next }) => { const isAuthed = false; if (!isAuthed) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } return next(); };
If you're upgrading from v0.1.x to v0.2.0, there are some changes to the middleware system:
const authMiddleware = async () => { return { user: { id: 'user-123' } }; }; const route = createZodRoute() .use(authMiddleware) .handler((req, ctx) => { const { user } = ctx.data; return { data: user.id }; });
import { type MiddlewareFunction } from 'next-zod-route'; const authMiddleware: MiddlewareFunction = async ({ next }) => { // Execute code before handler console.log('Checking auth...'); // Add context & continue chain const response = await next({ context: { user: { id: 'user-123' } }, }); // Modify response or execute code after return new Response(response.body, { headers: { ...Object.fromEntries(response.headers.entries()), 'X-User-Id': 'user-123', }, }); }; const route = createZodRoute() .use(authMiddleware) .handler((req, ctx) => { const { user } = ctx.data; return { data: user.id }; });
Key changes in v0.2.0:
request, context, metadata, and nextnext({ context: {...} })You can specify a custom error handler function to handle errors thrown in your route handler or middleware:
import { createZodRoute } from 'next-zod-route'; // Create a custom error class class CustomError extends Error { constructor( message: string, public status: number = 400, ) { super(message); this.name = 'CustomError'; } } // Create a route with a custom error handler const safeRoute = createZodRoute({ handleServerError: (error: Error) => { if (error instanceof CustomError) { return new Response(JSON.stringify({ message: error.message }), { status: error.status }); } // Default error response return new Response(JSON.stringify({ message: 'Internal server error' }), { status: 500 }); }, }); export const GET = safeRoute .use(async () => { // This error will be caught by the custom error handler throw new CustomError('Middleware error', 400); }) .handler((request, context) => { // This error will also be caught by the custom error handler throw new CustomError('Handler error', 400); });
By default, if no custom error handler is provided, the library will return a generic "Internal server error" message with a 500 status code to avoid information leakage.
When validation fails, the library returns appropriate error responses:
{ message: 'Invalid params' } with status 400{ message: 'Invalid query' } with status 400{ message: 'Invalid body' } with status 400Tests are written using Vitest. To run the tests, use the following command:
pnpm testContributions are welcome! For major changes, please open an issue first to discuss what you would like to change.
This project is licensed under the MIT License - see the LICENSE file for details.