.claude/skills/zod-validation/SKILL.md
Zod validation patterns for this Next.js application. Covers schema definition, API validation, form integration, error formatting, and type inference. Use this skill when implementing validation for APIs, forms, or entity schemas.
npx skillsauth add NextSpark-js/nextspark zod-validationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Patterns for implementing type-safe validation with Zod across APIs, forms, and entity schemas.
ZOD VALIDATION LAYERS:
API Layer:
├── Request body validation
├── Query parameter validation
└── Response type safety
Entity Layer:
├── Entity field validation
├── Create/Update schemas
└── Custom field validators
Form Layer:
├── React Hook Form integration
├── zodResolver for validation
└── Field-level error display
Block Layer:
├── baseBlockSchema extensions
├── Field definition schemas
└── Block-specific validations
import { z } from 'zod'
// Simple object schema
const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email format'),
age: z.number().int().min(18, 'Must be 18+').optional(),
})
// With enum validation
const statusSchema = z.enum(['active', 'inactive', 'pending'])
// With defaults
const settingsSchema = z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
language: z.string().default('en'),
})
// Nullable vs Optional
const profileSchema = z.object({
bio: z.string().optional(), // undefined allowed
avatar: z.string().nullable(), // null allowed
phone: z.string().nullish(), // null or undefined
})
// Transform input
const slugSchema = z.string()
.transform(s => s.toLowerCase().replace(/\s+/g, '-'))
// Trim whitespace
const cleanStringSchema = z.string().trim()
// Parse JSON string
const jsonSchema = z.string().transform(s => JSON.parse(s))
// Coerce types
const numberFromString = z.coerce.number() // "123" → 123
const dateFromString = z.coerce.date() // "2024-01-01" → Date
// Single refinement
const passwordSchema = z.string()
.min(8, 'Minimum 8 characters')
.refine(p => /[A-Z]/.test(p), 'Must contain uppercase')
.refine(p => /[0-9]/.test(p), 'Must contain number')
.refine(p => /[!@#$%^&*]/.test(p), 'Must contain special character')
// Cross-field validation
const signupSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // Error appears on this field
})
// Async refinement (DB check)
const uniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await checkEmailExists(email)
return !exists
},
{ message: 'Email already registered' }
)
// Extend schema
const baseUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
})
const adminSchema = baseUserSchema.extend({
role: z.literal('admin'),
permissions: z.array(z.string()),
})
// Merge schemas
const fullSchema = schemaA.merge(schemaB)
// Pick/Omit fields
const loginSchema = userSchema.pick({ email: true, password: true })
const publicUserSchema = userSchema.omit({ password: true })
// Partial (all optional)
const updateUserSchema = userSchema.partial()
// Required (all required)
const strictSchema = partialSchema.required()
// Infer type from schema
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
})
type User = z.infer<typeof userSchema>
// Result: { name: string; email: string; age?: number }
// Input vs Output types (when using transform)
type UserInput = z.input<typeof userSchema> // Before transform
type UserOutput = z.output<typeof userSchema> // After transform
// Partial type
type UpdateUser = z.infer<typeof userSchema.partial()>
// Result: { name?: string; email?: string; age?: number }
// Pick specific fields
type UserName = z.infer<typeof userSchema.pick({ name: true })>
// Result: { name: string }
import { z } from 'zod'
import { NextRequest } from 'next/server'
import { createApiError, createApiResponse } from '@/core/lib/api/helpers'
// Define schema
const createCustomerSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email format'),
phone: z.string().optional(),
notes: z.string().max(500).optional(),
})
export async function POST(request: NextRequest) {
// 1. Parse request body
const body = await request.json()
// 2. Validate with safeParse (no exceptions)
const result = createCustomerSchema.safeParse(body)
// 3. Handle validation error
if (!result.success) {
return createApiError(
'Validation error',
400,
result.error.issues, // Array of ZodIssue
'VALIDATION_ERROR'
)
}
// 4. Use validated, type-safe data
const validatedData = result.data // Fully typed!
// 5. Process
const customer = await CustomerService.create(
userId,
teamId,
validatedData
)
return createApiResponse(customer, 201)
}
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['createdAt', 'name', 'updatedAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
status: z.enum(['active', 'inactive', 'all']).default('all'),
})
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const queryObject = Object.fromEntries(searchParams)
const result = listQuerySchema.safeParse(queryObject)
if (!result.success) {
return createApiError('Invalid query parameters', 400, result.error.issues)
}
const { page, limit, sort, order, search, status } = result.data
// ... use validated params
}
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 1. Define schema
const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(6, 'Minimum 6 characters'),
})
// 2. Infer type
type LoginFormData = z.infer<typeof loginSchema>
// 3. Use in component
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
mode: 'onSubmit',
reValidateMode: 'onChange',
defaultValues: {
email: '',
password: '',
},
})
const onSubmit = async (data: LoginFormData) => {
// data is fully typed and validated
await signIn(data.email, data.password)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
<input type="password" {...register('password')} />
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</button>
</form>
)
}
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/core/components/ui/form'
import { Input } from '@/core/components/ui/input'
import { Button } from '@/core/components/ui/button'
function CustomerForm() {
const form = useForm<CustomerFormData>({
resolver: zodResolver(customerSchema),
defaultValues: { name: '', email: '' },
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage /> {/* Auto-displays error */}
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
)
}
// When safeParse fails:
result.error // ZodError instance
result.error.issues // Array<ZodIssue>
// ZodIssue structure:
interface ZodIssue {
code: string // "too_small", "invalid_type", "custom"
message: string // Error message
path: (string|number)[] // Field path: ["user", "email"]
minimum?: number // For min validations
maximum?: number // For max validations
type?: string // Expected type
}
const result = schema.safeParse(data)
if (!result.success) {
// Flatten for cleaner API response
const flattened = result.error.flatten()
// {
// formErrors: [], // Root-level errors
// fieldErrors: {
// email: ['Invalid email'],
// name: ['Required']
// }
// }
return createApiError('Validation error', 400, flattened.fieldErrors)
}
const formatted = result.error.format()
// {
// _errors: [], // Root errors
// email: { _errors: ['Invalid email'] },
// name: { _errors: ['Required'] }
// }
import { z } from 'zod'
import { baseBlockSchema } from '@/core/types/blocks'
// baseBlockSchema provides:
// - title: z.string().optional()
// - content: z.string().optional()
// - cta: z.object({ text, link, target }).optional()
// - backgroundColor: z.enum([...]).optional()
// - className: z.string().optional()
// - id: z.string().optional()
// Extend with custom fields
export const schema = baseBlockSchema.merge(z.object({
// Custom fields only - base fields inherited
features: z.array(z.object({
icon: z.string().optional(),
title: z.string().min(1, 'Title required'),
description: z.string().optional(),
})).min(1).max(12),
columns: z.enum(['2', '3', '4']).default('3'),
showIcons: z.boolean().default(true),
}))
export type FeaturesGridProps = z.infer<typeof schema>
// Function that returns schema with translated messages
export function createLoginSchema(t: (key: string) => string) {
return z.object({
email: z.string()
.min(1, t('validation.email.required'))
.email(t('validation.email.invalid')),
password: z.string()
.min(1, t('validation.password.required'))
.min(6, t('validation.password.minLength')),
})
}
// Usage in component
const { t } = useTranslations('auth')
const schema = createLoginSchema(t)
| Schema | Location | Purpose |
|--------|----------|---------|
| baseBlockSchema | core/types/blocks.ts | Base for all blocks |
| ctaSchema | core/types/blocks.ts | CTA button definition |
| teamSchema | core/lib/teams/schema.ts | Team CRUD validation |
| teamRoleSchema | core/lib/teams/schema.ts | Role validation |
| subscriptionStatusSchema | core/lib/billing/schema.ts | Subscription states |
| createPlanSchema | core/lib/billing/schema.ts | Plan creation |
| loginSchema | core/lib/validation-schemas.ts | Login form |
| signupSchema | core/lib/validation-schemas.ts | Signup form |
// NEVER: Use parse() in APIs (throws exceptions)
const data = schema.parse(body) // Throws on error!
// CORRECT: Use safeParse (returns result object)
const result = schema.safeParse(body)
if (!result.success) { /* handle error */ }
// NEVER: Catch and ignore validation errors
try {
const data = schema.parse(body)
} catch (e) {
// Don't swallow errors!
}
// CORRECT: Handle errors explicitly
const result = schema.safeParse(body)
if (!result.success) {
return createApiError('Validation error', 400, result.error.issues)
}
// NEVER: Duplicate base schema fields
const blockSchema = z.object({
title: z.string(), // Already in baseBlockSchema!
content: z.string(),
// ...custom fields
})
// CORRECT: Extend baseBlockSchema
const blockSchema = baseBlockSchema.merge(z.object({
// Only custom fields
customField: z.string(),
}))
// NEVER: Hardcode error messages without i18n support
const schema = z.string().min(1, 'Este campo es requerido')
// CORRECT: Use translation function
const schema = z.string().min(1, t('validation.required'))
// NEVER: Skip type inference
const handleSubmit = (data: any) => { ... }
// CORRECT: Use inferred types
type FormData = z.infer<typeof schema>
const handleSubmit = (data: FormData) => { ... }
Before finalizing validation implementation:
safeParse instead of parse in APIsz.infer<typeof schema>baseBlockSchemazodResolver with React Hook Formerror.issues in response.refine() with proper pathentity-api - API endpoint patterns with validationshadcn-components - Form component patternspage-builder-blocks - Block schema patternsdevelopment
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
testing
Test coverage metrics and registry system for this Next.js application. Covers FEATURE_REGISTRY, FLOW_REGISTRY, TAGS_REGISTRY, and coverage metrics interpretation. Use this skill when evaluating test coverage, identifying gaps, or planning testing priorities.
development
TanStack Query (React Query) patterns for data fetching in this Next.js application. Covers useQuery, useMutation, optimistic updates, cache invalidation, and anti-patterns. Use this skill when implementing data fetching or state management with server data.
development
Tailwind CSS theming system for this Next.js application. Covers CSS variables, semantic tokens, dark mode, buildSectionClasses, and theme build process. Use this skill when implementing UI styling or working with theme configurations.