logoPressFast

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 zod

Basic 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'],
  }
);
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_URL

Reusable 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

  1. Validate on both sides: Client for UX, server for security
  2. Use TypeScript: Get type safety from schemas
  3. Sanitize input: Remove dangerous content
  4. Whitelist, don't blacklist: Define what's allowed
  5. Validate file uploads: Check type, size, content
  6. Transform data: Normalize before storing
  7. Reuse schemas: DRY principle
  8. 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

Schema Validation