Schema Validation
Validate user input with Zod
Schema Validation
Always validate user input on both client and server to prevent security issues.
Why Validation?
- Prevent SQL injection (Prisma protects against this)
- Stop malicious data from entering your system
- Ensure data integrity
- Provide better error messages
- Type safety
Zod Schema Validation
Installation
pnpm add zodBasic Validation
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2, 'Name must be at least 2 characters'),
});
type User = z.infer<typeof userSchema>;
// Validate data
const result = userSchema.safeParse(data);
if (!result.success) {
console.error('Validation errors:', result.error.errors);
} else {
console.log('Valid data:', result.data);
}API Route Validation
// app/api/users/route.ts
import { z } from 'zod';
import { NextResponse } from 'next/server';
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2).max(50),
});
export async function POST(request: Request) {
try {
const body = await request.json();
// Validate input
const validatedData = createUserSchema.parse(body);
// Create user with validated data
const user = await db.user.create({
data: validatedData,
});
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation failed',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}Common Validation Patterns
Email Validation
const emailSchema = z.string().email();
// Custom email validation
const customEmailSchema = z.string().refine(
(email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
{ message: 'Invalid email format' }
);Password Validation
const passwordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number')
.regex(/[^A-Za-z0-9]/, 'Password must contain a special character');URL Validation
const urlSchema = z.string().url('Invalid URL');
// Or with custom validation
const httpsOnlySchema = z.string().refine(
(url) => url.startsWith('https://'),
{ message: 'URL must use HTTPS' }
);Phone Number Validation
const phoneSchema = z.string().regex(
/^\+?[1-9]\d{1,14}$/,
'Invalid phone number'
);Date Validation
const dateSchema = z.string().datetime();
// Or with custom validation
const futureDate = z.string().refine(
(date) => new Date(date) > new Date(),
{ message: 'Date must be in the future' }
);Complex Validation
Conditional Fields
const schema = z.object({
type: z.enum(['individual', 'company']),
name: z.string(),
companyName: z.string().optional(),
}).refine(
(data) => {
if (data.type === 'company') {
return !!data.companyName;
}
return true;
},
{
message: 'Company name is required for company accounts',
path: ['companyName'],
}
);Related Fields
const passwordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'],
}
);Array Validation
const tagsSchema = z.array(z.string())
.min(1, 'At least one tag is required')
.max(5, 'Maximum 5 tags allowed');
const usersSchema = z.array(
z.object({
id: z.string(),
email: z.string().email(),
})
);Nested Objects
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/),
country: z.string(),
});
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
address: addressSchema,
});Sanitization
Trim Whitespace
const schema = z.object({
name: z.string().trim().min(2),
email: z.string().trim().email(),
});Transform Data
const schema = z.object({
email: z.string().email().toLowerCase(),
age: z.string().transform((val) => parseInt(val, 10)),
tags: z.string().transform((val) => val.split(',')),
});Remove HTML
import sanitizeHtml from 'sanitize-html';
const contentSchema = z.string().transform((val) =>
sanitizeHtml(val, {
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
allowedAttributes: {
a: ['href'],
},
})
);Server Action Validation
// app/actions/user.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const updateUserSchema = z.object({
name: z.string().min(2).max(50),
bio: z.string().max(500).optional(),
});
export async function updateUser(formData: FormData) {
const rawData = {
name: formData.get('name'),
bio: formData.get('bio'),
};
try {
const validatedData = updateUserSchema.parse(rawData);
// Update user in database
await db.user.update({
where: { id: userId },
data: validatedData,
});
revalidatePath('/profile');
return { success: true };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.errors,
};
}
return {
success: false,
error: 'Failed to update user',
};
}
}File Upload Validation
const fileSchema = z.object({
name: z.string(),
size: z.number().max(5 * 1024 * 1024, 'File must be less than 5MB'),
type: z.enum(['image/jpeg', 'image/png', 'image/gif']),
});
export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get('file') as File;
const result = fileSchema.safeParse({
name: file.name,
size: file.size,
type: file.type,
});
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid file', details: result.error.errors },
{ status: 400 }
);
}
// Process file
}Environment Variable Validation
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_PUBLIC_KEY: z.string().startsWith('pk_'),
});
export const env = envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_PUBLIC_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
});
// Now use env.DATABASE_URL instead of process.env.DATABASE_URLReusable Schemas
// lib/schemas.ts
import { z } from 'zod';
export const emailSchema = z.string().email();
export const passwordSchema = z.string().min(8);
export const nameSchema = z.string().min(2).max(50);
export const urlSchema = z.string().url();
export const paginationSchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().positive().max(100).default(10),
});
export const idSchema = z.string().cuid();Best Practices
- Validate on both sides: Client for UX, server for security
- Use TypeScript: Get type safety from schemas
- Sanitize input: Remove dangerous content
- Whitelist, don't blacklist: Define what's allowed
- Validate file uploads: Check type, size, content
- Transform data: Normalize before storing
- Reuse schemas: DRY principle
- Test validation: Unit test your schemas
Testing Validation
// __tests__/validation.test.ts
import { describe, it, expect } from 'vitest';
import { userSchema } from '@/lib/schemas';
describe('User Schema', () => {
it('should validate correct data', () => {
const result = userSchema.safeParse({
email: 'test@example.com',
password: 'SecurePass123!',
name: 'John Doe',
});
expect(result.success).toBe(true);
});
it('should reject invalid email', () => {
const result = userSchema.safeParse({
email: 'invalid-email',
password: 'SecurePass123!',
name: 'John Doe',
});
expect(result.success).toBe(false);
});
it('should reject short password', () => {
const result = userSchema.safeParse({
email: 'test@example.com',
password: 'short',
name: 'John Doe',
});
expect(result.success).toBe(false);
});
});Next Steps
- Implement Rate Limiting
- Configure Security Headers
- Learn about Error Handling