Observed since 21 Oct, 08:55 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
@0xgen02/next-zod-route
A zod way to define route handlers in Next.js
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 with enhanced error handling capabilities.
RouteHandlerError interface providing status codes, error codes, and metadata for better API consistency and debugging.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, type RouteHandlerError } 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:
A. Return a Response object directly:
return NextResponse.json({ data: "value" }, { status: 200 });
B. Return a plain object that will be automatically converted to a JSON response with 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: {...} })next-zod-route provides a structured error interface for better error handling and API consistency:
import { type RouteHandlerError, createZodRoute } from "next-zod-route";
// The RouteHandlerError interface extends the standard Error
interface RouteHandlerError extends Error {
status: number; // HTTP status code (required)
code: string; // Application error code (required)
metadata?: Record<string, unknown>; // Additional error context (optional)
}
You can create custom error classes that implement the RouteHandlerError interface:
import { type RouteHandlerError } from "next-zod-route";
// Validation error with field details
class ValidationError extends Error implements RouteHandlerError {
status = 422;
code = "VALIDATION_ERROR";
metadata: Record<string, unknown>;
constructor(message: string, fields: Record<string, string>) {
super(message);
this.name = "ValidationError";
this.metadata = {
fields,
timestamp: new Date().toISOString(),
};
}
}
// Authentication error
class AuthError extends Error implements RouteHandlerError {
status = 401;
code = "UNAUTHORIZED";
constructor(message: string = "Authentication required") {
super(message);
this.name = "AuthError";
}
}
// Permission error with required permissions
class PermissionError extends Error implements RouteHandlerError {
status = 403;
code = "INSUFFICIENT_PERMISSIONS";
metadata: Record<string, unknown>;
constructor(message: string, required: string[], current: string[]) {
super(message);
this.name = "PermissionError";
this.metadata = {
required,
current,
resource: "API endpoint",
};
}
}
// Rate limiting error
class RateLimitError extends Error implements RouteHandlerError {
status = 429;
code = "RATE_LIMIT_EXCEEDED";
metadata: Record<string, unknown>;
constructor(limit: number, resetTime: string) {
super("Too many requests");
this.name = "RateLimitError";
this.metadata = {
limit,
resetTime,
retryAfter: 60,
};
}
}
You can specify a custom error handler function to handle RouteHandlerError instances:
import { type RouteHandlerError, createZodRoute } from 'next-zod-route';
// Enhanced error handler that uses RouteHandlerError properties
const safeRoute = createZodRoute({
handleServerError: (error: RouteHandlerError) => {
// Log error with structured data
console.error(`[${error.code}] ${error.message}`, {
status: error.status,
metadata: error.metadata,
stack: error.stack
});
// Handle different error types
if (error.code === 'VALIDATION_ERROR') {
return new Response(JSON.stringify({
error: error.message,
code: error.code,
fields: error.metadata?.fields
}), {
status: error.status,
headers: { 'Content-Type': 'application/json' }
});
}
if (error.code === 'RATE_LIMIT_EXCEEDED') {
return new Response(JSON.stringify({
error: error.message,
code: error.code,
retryAfter: error.metadata?.retryAfter
status: error.status,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(error.metadata?.retryAfter || 60)
}
});
}
// Default structured error response
return new Response(JSON.stringify({
error: error.message,
code: error.code,
timestamp: new Date().toISOString()
}), {
status: error.status,
headers: { 'Content-Type': 'application/json' }
});
},
});
// Usage in route handlers
export const POST = safeRoute
.body(z.object({
email: z.string().email(),
age: z.number().min(18)
}))
.handler((request, context) => {
const { email, age } = context.body;
// Throw structured validation error
if (!email.includes('@company.com')) {
throw new ValidationError('Invalid email domain', {
email: 'Must be a company email address'
});
}
// Throw permission error
if (age < 21) {
throw new PermissionError('Access denied', ['adult'], ['minor']);
}
return { success: true, user: { email, age } };
});
With the RouteHandlerError interface, your API responses become more consistent and informative:
// Validation Error Response (422)
{
"error": "Invalid input data",
"code": "VALIDATION_ERROR",
"fields": {
"email": "Invalid email format",
"age": "Must be greater than 18"
}
}
// Authentication Error Response (401)
{
"error": "Authentication required",
"code": "UNAUTHORIZED",
"timestamp": "2025-08-07T10:30:00Z"
}
// Permission Error Response (403)
{
"error": "Access denied",
"code": "INSUFFICIENT_PERMISSIONS",
"metadata": {
"required": ["admin", "write"],
"current": ["user", "read"],
"resource": "API endpoint"
}
}
// Rate Limit Error Response (429)
{
"error": "Too many requests",
"code": "RATE_LIMIT_EXCEEDED",
"retryAfter": 60
}
Note: The additional fields like fields, timestamp, retryAfter, etc. come from the metadata property of your custom error classes and are included in the response by your custom error handler.
status, code, and optional metadatametadata for better debugging and client handlingBy 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 test
Contributions 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.
Download counts and popularity will appear here as the collector receives them.
| Package / target | Requirement | Relationship |
|---|---|---|
zodEnvironment{
"local_name": "zod",
"alias": false,
"peer_metadata": null
} | ^4.0.14 | runtime |
tsupEnvironment{
"local_name": "tsup",
"alias": false,
"peer_metadata": null
} | ^8.5.0 | development |
huskyEnvironment{
"local_name": "husky",
"alias": false,
"peer_metadata": null
} | ^9.1.7 | development |
eslintEnvironment{
"local_name": "eslint",
"alias": false,
"peer_metadata": null
} | ^9.32.0 | development |
vitestEnvironment{
"local_name": "vitest",
"alias": false,
"peer_metadata": null
} | ^3.2.4 | development |
prettierEnvironment{
"local_name": "prettier",
"alias": false,
"peer_metadata": null
} | ^3.6.2 | development |
@swc/coreEnvironment{
"local_name": "@swc/core",
"alias": false,
"peer_metadata": null
} | ^1.13.3 | development |
@eslint/jsEnvironment{
"local_name": "@eslint/js",
"alias": false,
"peer_metadata": null
} | ^9.32.0 | development |
parse-pathEnvironment{
"local_name": "parse-path",
"alias": false,
"peer_metadata": null
} | ^7.1.0 | development |
release-itEnvironment{
"local_name": "release-it",
"alias": false,
"peer_metadata": null
} | ^19.0.4 | development |
typescriptEnvironment{
"local_name": "typescript",
"alias": false,
"peer_metadata": null
} | ^5.9.2 | development |
@types/nodeEnvironment{
"local_name": "@types/node",
"alias": false,
"peer_metadata": null
} | ^24.1.0 | development |
lint-stagedEnvironment{
"local_name": "lint-staged",
"alias": false,
"peer_metadata": null
} | ^16.1.2 | development |
typescript-eslintEnvironment{
"local_name": "typescript-eslint",
"alias": false,
"peer_metadata": null
} | ^8.38.0 | development |
@vitest/coverage-v8Environment{
"local_name": "@vitest/coverage-v8",
"alias": false,
"peer_metadata": null
} | ^3.2.4 | development |
@tronite/style-guideEnvironment{
"local_name": "@tronite/style-guide",
"alias": false,
"peer_metadata": null
} | ^0.0.13 | development |
@typescript-eslint/parserEnvironment{
"local_name": "@typescript-eslint/parser",
"alias": false,
"peer_metadata": null
} | ^8.38.0 | development |
@typescript-eslint/eslint-pluginEnvironment{
"local_name": "@typescript-eslint/eslint-plugin",
"alias": false,
"peer_metadata": null
} | ^8.38.0 | development |
Source distribution
{
"shasum": "e099d0fd7b7805334ea68da6dbba90b8d7b1d47a",
"tarball": "https://registry.npmjs.org/@0xgen02/next-zod-route/-/next-zod-route-1.1.0.tgz",
"fileCount": 9,
"integrity": "sha512-gBZzlIp7heg6Y4f9vg+LB8GuylFiEMDt0kXCw7Tye87Awvk+MG+QYJHLVqpTkar85SSFLEsZdXxUpMFzCsI0Zg==",
"signatures": [
{
"sig": "MEQCIH4Gdpg9tGKdiL9PdnfEyyV834hT1bop672CHx4fE0bqAiBdRyEKq42kLOKvDKWhu8bm2C8BQrexNsBIeV7NC6HGoQ==",
"keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"
}
],
"unpackedSize": 85314
}Observed since 21 Oct, 08:55 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package_version:0017def81ecb164f271a537644d782b1cddfd6350b72b623d5f27a135e08b513