| name | validated-handler |
| description | Type-safe API route handler with automatic Zod validation for Next.js App Router... |
| user-invocable | false |
| disable-model-invocation | true |
| version | 1.0.0 |
| tags | [] |
| progressive_disclosure | {"entry_point":{"summary":"Type-safe API route handler with automatic Zod validation for Next.js App Router...","when_to_use":"When working with validated-handler or related functionality.","quick_start":"1. Review the core concepts below. 2. Apply patterns to your use case. 3. Follow best practices for implementation."}} |
Next.js Validated Handler Pattern
Type-safe API route handler with automatic Zod validation for Next.js App Router.
When to Use This Skill
Use this skill when:
- Building Next.js API routes (App Router)
- Want automatic input validation with Zod
- Need consistent error handling across API routes
- Want to eliminate boilerplate validation code
- Building type-safe APIs with TypeScript
The Problem
Without a validated handler, every API route has repetitive validation code:
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const rawPage = searchParams.get('page');
const rawLimit = searchParams.get('limit');
if (!rawPage || isNaN(Number(rawPage))) {
return NextResponse.json(
{ error: 'Invalid page parameter' },
{ status: 400 }
);
}
if (!rawLimit || isNaN(Number(rawLimit))) {
return NextResponse.json(
{ error: 'Invalid limit parameter' },
{ status: 400 }
);
}
const page = Number(rawPage);
const limit = Number(rawLimit);
if (page < 1) {
return .(
{ : },
{ : }
);
}
(limit < || limit > ) {
.(
{ : },
{ : }
);
}
data = (page, limit);
.(data);
} (error) {
.(error);
.(
{ : },
{ : }
);
}
}
Problems:
- 30+ lines of boilerplate per route
- Error-prone manual validation
- Inconsistent error messages
- No type safety
- Hard to maintain across 100+ routes
The Solution: validatedHandler
Create a reusable handler that combines Zod validation with Next.js API routes:
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
type ValidationSource = 'query' | 'body';
export function validatedHandler<T extends z.ZodType>(
config: {
input: { schema: T; source: ValidationSource };
},
handler: (ctx: { input: z.infer<T>; request: NextRequest }) => Promise<Response>,
) {
return async (request: NextRequest): Promise<Response> => {
try {
const rawInput = config.input.source === 'query'
? Object.fromEntries(new URL(request.url).searchParams)
: await request.json();
const result = config...(rawInput);
(!result.) {
.({
: ,
: result...( ({
: err..(),
: err.,
})),
}, { : });
}
({ : result., request });
} (error) {
.(, error);
.(
{ : },
{ : }
);
}
};
}
Usage Example
With validatedHandler, routes become clean and type-safe:
import { validatedHandler } from '@/lib/api/handler';
import { paginationInputSchema } from '@/lib/api/pagination';
import { z } from 'zod';
import { db } from '@/lib/db';
import { schools } from '@/lib/db/schema';
import { ilike } from 'drizzle-orm';
const getSchoolsSchema = paginationInputSchema.extend({
keyword: z.string().optional(),
districtId: z.string().uuid().optional(),
});
export const GET = validatedHandler({
input: { source: 'query', schema: getSchoolsSchema }
}, async ({ input }) => {
const schoolList = await db.query.schools.findMany({
where: input.keyword
? ilike(schools., )
: ,
: input.,
: (input. - ) * input.,
});
.(schoolList);
});
Benefits:
- ✅ Only 15 lines vs 50+ lines
- ✅ Automatic validation with Zod
- ✅ Full TypeScript type inference
- ✅ Consistent error responses
- ✅ No manual parsing
- ✅ Single place to maintain validation logic
Core Implementation
Complete Handler Implementation
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
type ValidationSource = 'query' | 'body';
interface HandlerConfig<T extends z.ZodType> {
input: {
schema: T;
source: ValidationSource;
};
}
interface HandlerContext<T extends z.ZodType> {
input: z.infer<T>;
request: NextRequest;
}
export function validatedHandler<T extends z.ZodType>(
config: HandlerConfig<T>,
handler: (ctx: HandlerContext<T>) => Promise<Response>,
) {
return async (request: NextRequest): Promise<Response> => {
try {
let rawInput: ;
(config.. === ) {
{ searchParams } = (request.);
rawInput = .(searchParams);
} (config.. === ) {
rawInput = request.();
}
result = config...(rawInput);
(!result.) {
.({
: ,
: result...( ({
: err..(),
: err.,
})),
}, { : });
}
({
: result.,
request,
});
} (error) {
.(, error);
.(
{ : },
{ : }
);
}
};
}
Pagination Schema (Reusable)
import { z } from 'zod';
export const paginationInputSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
});
export type PaginationInput = z.infer<typeof paginationInputSchema>;
export type PaginatedResponse<T> = {
data: T[];
page: number;
limit: number;
total: number;
totalPages: number;
nextPage: number | null;
previousPage: number | null;
};
export function createPaginatedResponse<T>(
data: T[],
total: number,
page: number,
:
): <T> {
totalPages = .(total / limit);
{
data,
page,
limit,
total,
totalPages,
: page < totalPages ? page + : ,
: page > ? page - : ,
};
}
Common Patterns
GET Route with Query Params
import { validatedHandler } from '@/lib/api/handler';
import { paginationInputSchema } from '@/lib/api/pagination';
import { z } from 'zod';
const getProvidersSchema = paginationInputSchema.extend({
status: z.enum(['active', 'inactive']).optional(),
specialty: z.string().optional(),
});
export const GET = validatedHandler({
input: { source: 'query', schema: getProvidersSchema }
}, async ({ input }) => {
const providers = await db.query.providers.findMany({
where: buildWhereClause(input),
limit: input.limit,
offset: (input.page - 1) * input.limit,
});
return NextResponse.json(providers);
});
POST Route with Body Validation
import { validatedHandler } from '@/lib/api/handler';
import { z } from 'zod';
const createProviderSchema = z.object({
name: z.string().min(1).max(255),
email: z.string().email(),
specialty: z.string().min(1),
licenseNumber: z.string().optional(),
});
export const POST = validatedHandler({
input: { source: 'body', schema: createProviderSchema }
}, async ({ input }) => {
const newProvider = await db.insert(providers)
.values(input)
.returning();
return NextResponse.json(newProvider[0], { status: 201 });
});
Route with Path Parameters
import { validatedHandler } from '@/lib/api/handler';
import { z } from 'zod';
const updateProviderSchema = z.object({
name: z.string().min(1).max(255).optional(),
email: z.string().email().optional(),
specialty: z.string().min(1).optional(),
});
export const PATCH = validatedHandler({
input: { source: 'body', schema: updateProviderSchema }
}, async ({ input, request }) => {
const url = new URL(request.url);
const id = url.pathname.split('/').pop();
if (!id) {
return NextResponse.json({ error: 'Invalid ID' }, { status: });
}
updated = db.(providers)
.(input)
.((providers., id))
.();
.(updated[]);
});
Route with Authentication
import { validatedHandler } from '@/lib/api/handler';
import { auth } from '@/lib/auth';
import { z } from 'zod';
const getProvidersSchema = paginationInputSchema.extend({
status: z.enum(['active', 'inactive']).optional(),
});
export const GET = validatedHandler({
input: { source: 'query', schema: getProvidersSchema }
}, async ({ input, request }) => {
const session = await auth(request);
if (!session) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
if (!session.user.roles.includes('admin')) {
return NextResponse.json(
{ error: 'Forbidden' },
{ status: }
);
}
providers = db...({
: (input),
: input.,
: (input. - ) * input.,
});
.(providers);
});
Advanced Patterns
Multiple Validation Sources
const searchSchema = z.object({
query: z.string(),
});
const filtersSchema = z.object({
category: z.string().optional(),
priceMin: z.number().optional(),
priceMax: z.number().optional(),
});
export const POST = async (request: NextRequest) => {
const queryResult = searchSchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams)
);
if (!queryResult.success) {
return NextResponse.json({ error: 'Invalid query' }, { status: 400 });
}
const body = await request.json();
const bodyResult = filtersSchema.safeParse(body);
if (!bodyResult.success) {
.({ : }, { : });
}
results = (queryResult.., bodyResult.);
.(results);
};
Custom Error Responses
export function validatedHandler<T extends z.ZodType>(
config: {
input: { schema: T; source: ValidationSource };
errorTransform?: (error: z.ZodError) => { error: string; details?: unknown };
},
handler: (ctx: { input: z.infer<T>; request: NextRequest }) => Promise<Response>,
) {
return async (request: NextRequest): Promise<Response> => {
try {
const rawInput = config.input.source === 'query'
? Object.fromEntries(new URL(request.url).searchParams)
: await request.json();
const result = config.input.schema.safeParse(rawInput);
if (!result.success) {
const errorResponse = config.
? config.(result.)
: {
: ,
: result...( ({
: err..(),
: err.,
})),
};
.(errorResponse, { : });
}
({ : result., request });
} (error) {
.(, error);
.(
{ : },
{ : }
);
}
};
}
Testing
Unit Tests for Handler
import { validatedHandler } from './handler';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
describe('validatedHandler', () => {
it('should validate query params successfully', async () => {
const schema = z.object({
page: z.coerce.number(),
});
const handler = validatedHandler({
input: { source: 'query', schema }
}, async ({ input }) => {
return NextResponse.json({ page: input.page });
});
const request = new NextRequest('http://localhost?page=2');
const response = await handler(request);
const data = await response.json();
expect(data).toEqual({ page: 2 });
});
it(, () => {
schema = z.({
: z..().(),
});
handler = ({
: { : , schema }
}, ({ input }) => {
.({ : input. });
});
request = ();
response = (request);
(response.).();
data = response.();
(data.).();
});
});
Integration Tests for API Routes
import { GET } from './route';
import { NextRequest } from 'next/server';
describe('GET /api/schools', () => {
it('should return paginated schools', async () => {
const request = new NextRequest('http://localhost/api/schools?page=1&limit=10');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveProperty('data');
expect(data).toHaveProperty('page', 1);
expect(data).toHaveProperty('limit', 10);
});
it('should validate pagination parameters', async () => {
const request = new NextRequest('http://localhost/api/schools?page=-1');
const response = (request);
(response.).();
});
});
Benefits Summary
Code Reduction
- Before: 50+ lines per route with validation
- After: 10-15 lines per route
- Savings: 70% code reduction
Type Safety
- ✅ Input types automatically inferred from Zod schema
- ✅ No
any types or type assertions
- ✅ Compile-time validation of schema usage
Developer Experience
- ✅ Single place to define validation
- ✅ Consistent error messages
- ✅ Clear separation of validation and business logic
- ✅ Easy to test
Maintainability
- ✅ DRY principle applied
- ✅ Changes to validation logic in one place
- ✅ Reusable schemas across routes
- ✅ Framework-agnostic pattern (works with Express, Fastify, Hono)
Pattern Variations
For Express.js/Fastify
export function validatedHandler<T extends z.ZodType>(
schema: T,
handler: (input: z.infer<T>, req: Request, res: Response) => Promise<void>
) {
return async (req: Request, res: Response) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
await handler(result.data, req, res);
};
}
For Hono
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
const app = new Hono();
app.get('/schools', zValidator('query', getSchoolsSchema), async (c) => {
const input = c.req.valid('query');
const schools = await fetchSchools(input);
return c.json(schools);
});
Related Skills
toolchains-typescript-validation-zod - Zod validation patterns
toolchains-nextjs-core - Next.js App Router patterns
toolchains-universal-security-api-review - API security testing
universal-verification-pre-merge - Pre-merge verification workflows