From harness-claude
Validates Next.js server actions, API routes, form data with useActionState/useFormState, search params, and dynamic route segments using Zod schemas.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Validate Next.js server actions, API routes, and form data with Zod schemas
Defines type-safe Zod schemas for forms, APIs, env vars; covers parsing, refinements, transformations, type inference, and integrations with React Hook Form, Next.js, tRPC.
Creates reusable Zod v4 schemas to validate API payloads, forms, and config inputs in TypeScript apps. Handles coercion, transforms, errors, and type inference for runtime type safety.
Uses Zod schemas for runtime validation of APIs, forms, env vars, and external data while inferring TypeScript types as single source of truth.
Share bugs, ideas, or general feedback.
Validate Next.js server actions, API routes, and form data with Zod schemas
useFormState / useActionStateFormData or typed arguments:'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content is too short'),
published: z.coerce.boolean().default(false),
});
export async function createPost(formData: FormData) {
const result = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published'),
});
if (!result.success) {
return { success: false, errors: result.error.flatten().fieldErrors };
}
const post = await db.post.create({ data: result.data });
redirect(`/posts/${post.id}`);
}
useActionState (React 19) or useFormState with typed state:'use client'
import { useActionState } from 'react'
import { createPost } from './actions'
type FormState = {
success: boolean
errors?: { title?: string[]; content?: string[] }
}
const initialState: FormState = { success: false }
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState)
return (
<form action={formAction}>
<input name="title" />
{state.errors?.title && <p>{state.errors.title[0]}</p>}
<textarea name="content" />
{state.errors?.content && <p>{state.errors.content[0]}</p>}
<button disabled={isPending}>Create</button>
</form>
)
}
import { z } from 'zod';
import { NextRequest, NextResponse } from 'next/server';
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'viewer']).default('viewer'),
});
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ success: false, errors: result.error.flatten().fieldErrors },
{ status: 422 }
);
}
const user = await db.user.create({ data: result.data });
return NextResponse.json({ success: true, user }, { status: 201 });
}
import { z } from 'zod';
const SearchParamsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
query: z.string().optional(),
sort: z.enum(['asc', 'desc']).default('desc'),
});
// In a Server Component:
export default function Page({ searchParams }: { searchParams: Record<string, string> }) {
const params = SearchParamsSchema.parse(searchParams);
// params.page, params.limit, params.query, params.sort are all properly typed
}
const RouteParamsSchema = z.object({
id: z.string().uuid('Invalid post ID format'),
});
export default async function PostPage({ params }: { params: { id: string } }) {
const result = RouteParamsSchema.safeParse(params);
if (!result.success) {
notFound();
}
const post = await db.post.findUniqueOrThrow({ where: { id: result.data.id } });
// ...
}
import { z } from 'zod';
type ActionResult<T> =
| { success: true; data: T }
| { success: false; errors: Record<string, string[]> };
export function createValidatedAction<TSchema extends z.ZodSchema, TResult>(
schema: TSchema,
handler: (data: z.infer<TSchema>) => Promise<TResult>
) {
return async (formData: FormData): Promise<ActionResult<TResult>> => {
const rawData = Object.fromEntries(formData.entries());
const result = schema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
};
}
const data = await handler(result.data);
return { success: true, data };
};
}
// Usage:
export const createPost = createValidatedAction(CreatePostSchema, async (data) => {
return db.post.create({ data });
});
FormData coercion:
All form values come in as strings. Use z.coerce for non-string fields: z.coerce.number(), z.coerce.boolean(), z.coerce.date(). Checkbox values are 'on' or absent — coerce with:
const boolField = z.preprocess(
(val) => val === 'on' || val === 'true' || val === true,
z.boolean()
);
File inputs:
const FileSchema = z.object({
file: z
.instanceof(File)
.refine((f) => f.size < 5 * 1024 * 1024, 'File must be under 5MB')
.refine((f) => ['image/jpeg', 'image/png'].includes(f.type), 'Must be JPEG or PNG'),
});
tRPC integration:
In tRPC procedures, pass Zod schemas directly to .input():
const postRouter = router({
create: protectedProcedure.input(CreatePostSchema).mutation(({ ctx, input }) => {
// input is fully typed as z.infer<typeof CreatePostSchema>
return ctx.db.post.create({ data: input });
}),
});