API Validation
beginnerType-safe request/response validation with Zod schemas.
apivalidationzodtypescript
Tested on⬢20▲16⚛19TS5.9
$ bunx sinew add api/api-validationInteractive demo coming soon
1The Problem
Without proper validation:
- Invalid data reaches your business logic
- Runtime errors are cryptic and hard to debug
- Types don't match reality at runtime
- Security vulnerabilities from unvalidated input
2The Solution
Use Zod to validate and transform data at API boundaries.
3Files
lib/validations/user.ts
lib/validations/user.tsTypeScript
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(2, "Name must be at least 2 characters"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain an uppercase letter")
.regex(/[0-9]/, "Password must contain a number"),
});
export const updateUserSchema = z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
});
export const userParamsSchema = z.object({
id: z.string().cuid("Invalid user ID"),
});
export const listUsersSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
sortBy: z.enum(["name", "email", "createdAt"]).default("createdAt"),
order: z.enum(["asc", "desc"]).default("desc"),
});
// Infer types from schemas
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type UserParams = z.infer<typeof userParamsSchema>;
export type ListUsersQuery = z.infer<typeof listUsersSchema>;lib/api-utils.ts
lib/api-utils.tsTypeScript
import { NextResponse } from "next/server";
import { ZodError, ZodSchema } from "zod";
export class ApiError extends Error {
constructor(
public message: string,
public status: number,
public code?: string
) {
super(message);
this.name = "ApiError";
}
}
export function parseBody<T>(schema: ZodSchema<T>, data: unknown): T {
try {
return schema.parse(data);
} catch (error) {
if (error instanceof ZodError) {
throw new ApiError("Validation failed", 400, "VALIDATION_ERROR");
}
throw error;
}
}
export function parseQuery<T>(schema: ZodSchema<T>, params: URLSearchParams): T {
const data = Object.fromEntries(params);
return parseBody(schema, data);
}
export function formatZodError(error: ZodError) {
return {
error: "Validation failed",
issues: error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
};
}
export function handleError(error: unknown) {
console.error(error);
if (error instanceof ZodError) {
return NextResponse.json(formatZodError(error), { status: 400 });
}
if (error instanceof ApiError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status });
}
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}app/api/users/route.ts
app/api/users/route.tsTypeScript
import { NextResponse } from "next/server";
import { createUserSchema, listUsersSchema } from "@/lib/validations/user";
import { handleError } from "@/lib/api-utils";
import { prisma } from "@/lib/db";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const query = listUsersSchema.parse(Object.fromEntries(searchParams));
const { page, limit, search, sortBy, order } = query;
const users = await prisma.user.findMany({
where: search
? {
OR: [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}
: undefined,
orderBy: { [sortBy]: order },
skip: (page - 1) * limit,
take: limit,
select: { id: true, name: true, email: true, createdAt: true },
});
const total = await prisma.user.count({
where: search
? {
OR: [
{ name: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}
: undefined,
});
return NextResponse.json({
data: users,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
} catch (error) {
return handleError(error);
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const data = createUserSchema.parse(body);
const user = await prisma.user.create({
data,
select: { id: true, name: true, email: true, createdAt: true },
});
return NextResponse.json(user, { status: 201 });
} catch (error) {
return handleError(error);
}
}app/api/users/[id]/route.ts
app/api/users/[id]/route.tsTypeScript
import { NextResponse } from "next/server";
import { userParamsSchema, updateUserSchema } from "@/lib/validations/user";
import { handleError, ApiError } from "@/lib/api-utils";
import { prisma } from "@/lib/db";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = userParamsSchema.parse(await params);
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, name: true, email: true, createdAt: true },
});
if (!user) {
throw new ApiError("User not found", 404, "NOT_FOUND");
}
return NextResponse.json(user);
} catch (error) {
return handleError(error);
}
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = userParamsSchema.parse(await params);
const body = await request.json();
const data = updateUserSchema.parse(body);
const user = await prisma.user.update({
where: { id },
data,
select: { id: true, name: true, email: true, createdAt: true },
});
return NextResponse.json(user);
} catch (error) {
return handleError(error);
}
}4Dependencies
$ bun add zod5Configuration
Common Validation Patterns
// Email
z.string().email();
// URL
z.string().url();
// UUID
z.string().uuid();
// CUID
z.string().cuid();
// ISO date string
z.string().datetime();
// Enum
z.enum(["active", "inactive", "pending"]);
// Object with specific keys
z.object({ name: z.string() }).strict();
// Array with constraints
z.array(z.string()).min(1).max(10);
// Optional with default
z.string().optional().default("default");
// Transform
z.string().transform((val) => val.toLowerCase());TypeScript
6Usage
Client-Side Validation
import { createUserSchema } from "@/lib/validations/user";
function handleSubmit(formData: FormData) {
const result = createUserSchema.safeParse({
email: formData.get("email"),
name: formData.get("name"),
password: formData.get("password"),
});
if (!result.success) {
// Show validation errors
return result.error.issues;
}
// Submit validated data
await fetch("/api/users", {
method: "POST",
body: JSON.stringify(result.data),
});
}TypeScript
7Troubleshooting
TypeScript types not matching
- Ensure you're using
z.infer<typeof schema>for types - Check that optional fields use
.optional()or.nullable()
Coercion not working
- Use
z.coerce.number()for query params (they're always strings) - Check the order of transforms in the schema chain