Next.js course Β· Module 6: Forms and Server Actions

Form Validation with Zod

7 min read
In this lesson11

In Quantum Metropolis, every system requires precise data validation - from access terminals to implant control panels. Zod is a validation library designed with TypeScript in mind that has become the standard in the Next.js ecosystem. Unlike other solutions, Zod automatically generates TypeScript types from validation schemas, eliminating code duplication.

Why Zod?

In the year 2150, Quantum Metropolis systems require reliable data validation. Zod stands out from other validation libraries (Yup, Joi) for several reasons:

  1. TypeScript-first - Zod schemas automatically generate types, eliminating duplication
  2. Zero dependencies - lightweight library (~10 KB gzipped)
  3. Next.js standard - officially recommended in Next.js documentation
  4. Works on server and client - ideal for Server Actions

Installation and Import

1npm install zod
1import { z } from 'zod';

Basic Zod Schemas

Zod offers a set of methods for defining data types - like digital DNA describing the structure of information in the system:

1import { z } from 'zod';
2
3// Primitive types
4const nameSchema = z.string();
5const ageSchema = z.number();
6const isActiveSchema = z.boolean();
7
8// Object schema - we combine types into a structure
9const userSchema = z.object({
10  name: z.string(),
11  email: z.string().email(),
12  age: z.number().int().positive(),
13  isActive: z.boolean(),
14});

Each z.string(), z.number(), z.boolean() is a basic building block of a schema. The z.object() method combines them into a comprehensive structure - like a terminal schema in Quantum Metropolis.

Validation Methods: parse and safeParse

Zod offers two ways to validate data:

1import { z } from 'zod';
2
3const schema = z.object({
4  name: z.string().min(2),
5  email: z.string().email(),
6});
7
8// 1. parse() - throws an exception for invalid data
9try {
10  const data = schema.parse({ name: "Neo", email: "neo@quantum.io" });
11  console.log("Valid data:", data);
12} catch (error) {
13  console.error("Validation error:", error);
14}
15
16// 2. safeParse() - does NOT throw, returns a result object
17const result = schema.safeParse({ name: "", email: "bad-email" });
18
19if (result.success) {
20  console.log("Data:", result.data);
21} else {
22  console.log("Errors:", result.error.errors);
23  // [{ path: ["name"], message: "String must contain at least 2 character(s)" }, ...]
24}

Rule: Use safeParse() in forms and Server Actions (safer), and parse() when you want to fail fast on invalid data.

Error Messages and Validation Rules

Zod allows you to precisely define validation rules with custom messages - like configuring a security system on a terminal:

1import { z } from 'zod';
2
3const registrationSchema = z.object({
4  username: z.string()
5    .min(3, { message: "Username must be at least 3 characters" })
6    .max(20, { message: "Username can be at most 20 characters" }),
7  email: z.string()
8    .email({ message: "Invalid email address format" }),
9  age: z.number()
10    .int({ message: "Age must be a whole number" })
11    .min(13, { message: "You must be at least 13 years old" }),
12  password: z.string()
13    .min(8, { message: "Password must be at least 8 characters" })
14    .regex(/[A-Z]/, { message: "Password must contain an uppercase letter" })
15    .regex(/[0-9]/, { message: "Password must contain a digit" }),
16  acceptTerms: z.boolean()
17    .refine(val => val === true, {
18      message: "You must accept the system terms of use"
19    }),
20});

The .refine() method allows you to define custom, non-standard validation rules - useful when built-in methods aren't enough.

Type Inference: z.infer

One of Zod's most powerful features is the automatic generation of TypeScript types from schemas. You don't need to maintain separate interfaces:

1import { z } from 'zod';
2
3const implantSchema = z.object({
4  id: z.string().uuid(),
5  name: z.string().min(1),
6  powerLevel: z.number().min(0).max(100),
7  isActive: z.boolean(),
8  tags: z.array(z.string()),
9});
10
11// Type automatically generated from schema
12type Implant = z.infer<typeof implantSchema>;
13// Manual equivalent: { id: string; name: string; powerLevel: number; isActive: boolean; tags: string[] }
14
15// Now type and validation are SYNCHRONIZED
16function processImplant(data: Implant) {
17  console.log(data.name); // TypeScript knows the type!
18}

Thanks to z.infer, you have a single source of truth - the schema defines both validation and the TypeScript type.

Integration with React Hook Form

Zod works great with React Hook Form thanks to the zodResolver adapter. This is the most commonly encountered pattern in Next.js applications:

1import { useForm } from 'react-hook-form';
2import { zodResolver } from '@hookform/resolvers/zod';
3import { z } from 'zod';
4
5// 1. Define the schema
6const loginSchema = z.object({
7  email: z.string()
8    .email({ message: "Invalid email address" }),
9  password: z.string()
10    .min(8, { message: "Password must be at least 8 characters" }),
11});
12
13// 2. Generate the type
14type LoginForm = z.infer<typeof loginSchema>;
15
16// 3. Use in the component
17export default function LoginPage() {
18  const {
19    register,
20    handleSubmit,
21    formState: { errors, isSubmitting }
22  } = useForm<LoginForm>({
23    resolver: zodResolver(loginSchema), // Zod as the validator
24    defaultValues: { email: '', password: '' }
25  });
26
27  const onSubmit = async (data: LoginForm) => {
28    // data is already validated and typed!
29    console.log("Logging in:", data);
30  };
31
32  return (
33    <form onSubmit={handleSubmit(onSubmit)}>
34      <input {...register('email')} placeholder="Email" />
35      {errors.email && <p>{errors.email.message}</p>}
36
37      <input {...register('password')} type="password" placeholder="Password" />
38      {errors.password && <p>{errors.password.message}</p>}
39
40      <button type="submit" disabled={isSubmitting}>
41        {isSubmitting ? 'Logging in...' : 'Log in'}
42      </button>
43    </form>
44  );
45}

The key line is resolver: zodResolver(loginSchema) - it connects the Zod schema with the React Hook Form validation system.

Reusable Schemas and Composition

In large applications, it's worth creating reusable schemas that can be combined and extended:

1import { z } from 'zod';
2
3// Base address schema - used in multiple places
4const addressSchema = z.object({
5  street: z.string().min(1, "Street is required"),
6  city: z.string().min(1, "City is required"),
7  postCode: z.string().regex(/^\d{2}-\d{3}$/, "Format: XX-XXX"),
8});
9
10// User schema - extending with address
11const userSchema = z.object({
12  name: z.string().min(2),
13  email: z.string().email(),
14  address: addressSchema,                    // Nesting
15  shippingAddress: addressSchema.optional(),  // Optional
16});
17
18// .merge() - merging two schemas
19const baseProfile = z.object({ name: z.string(), bio: z.string() });
20const socialProfile = z.object({ twitter: z.string(), github: z.string() });
21const fullProfile = baseProfile.merge(socialProfile);
22
23// .extend() - adding fields to an existing schema
24const adminUser = userSchema.extend({
25  role: z.literal('admin'),
26  permissions: z.array(z.string()),
27});
28
29type AdminUser = z.infer<typeof adminUser>;

Advanced Techniques: refine and superRefine

The .refine() method allows validation that can't be expressed declaratively - e.g. comparing two fields:

1import { z } from 'zod';
2
3const passwordSchema = z.object({
4  password: z.string().min(8),
5  confirmPassword: z.string(),
6}).refine(data => data.password === data.confirmPassword, {
7  message: "Passwords do not match",
8  path: ["confirmPassword"], // Error assigned to confirmPassword field
9});
10
11// superRefine - when you need multiple errors simultaneously
12const advancedSchema = z.object({
13  accountType: z.enum(['personal', 'business']),
14  personalId: z.string().optional(),
15  companyId: z.string().optional(),
16}).superRefine((data, ctx) => {
17  if (data.accountType === 'personal' && !data.personalId) {
18    ctx.addIssue({
19      code: z.ZodIssueCode.custom,
20      message: "Personal ID is required for a personal account",
21      path: ['personalId'],
22    });
23  }
24  if (data.accountType === 'business' && !data.companyId) {
25    ctx.addIssue({
26      code: z.ZodIssueCode.custom,
27      message: "Company ID is required for a business account",
28      path: ['companyId'],
29    });
30  }
31});

Alternatives: Yup and Joi

Zod is the standard in the Next.js ecosystem, but alternatives exist:

  • Yup - popular with Formik, declarative syntax, but requires manual TypeScript type definitions
  • Joi - powerful, feature-rich, but large (~40 KB) and mostly server-side

Zod is recommended for Next.js projects because it combines validation with TypeScript types and works on both server and client.

Summary

Zod is an essential tool in the Next.js developer's arsenal:

  • z.string(), z.number(), z.object() - defining schemas
  • parse() and safeParse() - data validation
  • .min(), .email(), .regex() - rules with error messages
  • .refine() - custom validation
  • zodResolver - integration with React Hook Form
  • z.infer<typeof schema> - automatic type generation
Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3// Simulation of validation with Zod - schemas, parse, safeParse, error messages
4
5// Simplified "Zod" for demonstration (in a real project you import z from the zod package)
6interface ZodIssue { path: string[]; message: string }
7interface ZodResult<T> { success: boolean; data?: T; error?: { issues: ZodIssue[] } }
8
9const z = {
10  string: () => {
11    const rules: Array<{ type: string; value?: any; msg: string }> = [];
12    const validator: any = {
13      min(n: number, opt?: { message?: string }) { rules.push({ type: 'min', value: n, msg: opt?.message || `Min ${n} characters` }); return validator; },
14      max(n: number, opt?: { message?: string }) { rules.push({ type: 'max', value: n, msg: opt?.message || `Max ${n} characters` }); return validator; },
15      email(opt?: { message?: string }) { rules.push({ type: 'email', msg: opt?.message || 'Invalid email' }); return validator; },
16      regex(re: RegExp, opt?: { message?: string }) { rules.push({ type: 'regex', value: re, msg: opt?.message || 'Invalid format' }); return validator; },
17      _validate(val: string): string | null {
18        if (typeof val !== 'string') return 'String is required';
19        for (const rule of rules) {
20          if (rule.type === 'min' && val.length < rule.value) return rule.msg;
21          if (rule.type === 'max' && val.length > rule.value) return rule.msg;
22          if (rule.type === 'email' && !/\S+@\S+\.\S+/.test(val)) return rule.msg;
23          if (rule.type === 'regex' && !rule.value.test(val)) return rule.msg;
24        }
25        return null;
26      },
27    };
28    return validator;
29  },
30  coerce: {
31    number: () => {
32      const rules: Array<{ type: string; value?: any; msg: string }> = [];
33      const validator: any = {
34        min(n: number, opt?: { message?: string }) { rules.push({ type: 'min', value: n, msg: opt?.message || `Min ${n}` }); return validator; },
35        max(n: number, opt?: { message?: string }) { rules.push({ type: 'max', value: n, msg: opt?.message || `Max ${n}` }); return validator; },
36        int(opt?: { message?: string }) { rules.push({ type: 'int', msg: opt?.message || 'An integer is required' }); return validator; },
37        _validate(val: string): string | null {
38          const num = Number(val);
39          if (val === '' || isNaN(num)) return 'A number is required';
40          for (const rule of rules) {
41            if (rule.type === 'min' && num < rule.value) return rule.msg;
42            if (rule.type === 'max' && num > rule.value) return rule.msg;
43            if (rule.type === 'int' && !Number.isInteger(num)) return rule.msg;
44          }
45          return null;
46        },
47      };
48      return validator;
49    },
50  },
51  object: (shape: Record<string, any>) => ({
52    safeParse(data: Record<string, string>): ZodResult<any> {
53      const issues: ZodIssue[] = [];
54      const parsed: any = {};
55      for (const [key, validator] of Object.entries(shape)) {
56        const err = validator._validate(data[key] || '');
57        if (err) issues.push({ path: [key], message: err });
58        else parsed[key] = data[key];
59      }
60      if (issues.length > 0) return { success: false, error: { issues } };
61      return { success: true, data: parsed };
62    },
63    parse(data: Record<string, string>) {
64      const result = this.safeParse(data);
65      if (!result.success) throw new Error('Zod validation failed: ' + result.error!.issues.map(i => i.message).join(', '));
66      return result.data;
67    },
68  }),
69};
70
71// --- ZOD SCHEMA ---
72// In a real project z comes from the zod package
73const implantSchema = z.object({
74  name: z.string()
75    .min(2, { message: 'Name must have at least 2 characters' })
76    .max(50, { message: 'Name can have at most 50 characters' }),
77  email: z.string()
78    .email({ message: 'Invalid email format' }),
79  powerLevel: z.coerce.number()
80    .min(1, { message: 'Power level must be greater than 0' })
81    .max(100, { message: 'Power level cannot exceed 100' }),
82});
83
84// type Implant = z.infer<typeof implantSchema>;
85
86export default function ZodValidationDemo() {
87  const [values, setValues] = useState({ name: '', email: '', powerLevel: '' });
88  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
89  const [result, setResult] = useState<{ type: 'success' | 'error'; data: string } | null>(null);
90  const [mode, setMode] = useState<'safeParse' | 'parse'>('safeParse');
91
92  const handleSubmit = (e: React.FormEvent) => {
93    e.preventDefault();
94    setFieldErrors({});
95    setResult(null);
96
97    if (mode === 'safeParse') {
98      const res = implantSchema.safeParse(values);
99      if (res.success) {
100        setResult({ type: 'success', data: JSON.stringify(res.data, null, 2) });
101      } else {
102        const errs: Record<string, string> = {};
103        for (const issue of res.error!.issues) {
104          errs[issue.path[0]] = issue.message;
105        }
106        setFieldErrors(errs);
107        setResult({ type: 'error', data: JSON.stringify(res.error!.issues, null, 2) });
108      }
109    } else {
110      try {
111        const data = implantSchema.parse(values);
112        setResult({ type: 'success', data: JSON.stringify(data, null, 2) });
113      } catch (err: any) {
114        setResult({ type: 'error', data: err.message });
115      }
116    }
117  };
118
119  const inputStyle: React.CSSProperties = {
120    width: '100%', padding: '10px', background: '#0d1117',
121    border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
122    fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
123  };
124
125  const schemaCode = `const implantSchema = z.object({
126  name: z.string().min(2).max(50),
127  email: z.string().email(),
128  powerLevel: z.coerce.number().min(1).max(100),
129});
130
131type Implant = z.infer<typeof implantSchema>;`;
132
133  return (
134    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
135      <h1 style={{ color: '#64ffda' }}>Validation with Zod</h1>
136      <p style={{ color: '#888', fontSize: 13, marginBottom: 12 }}>
137        Test the Zod schema - enter data and choose a validation method.
138      </p>
139
140      <pre style={{ background: '#0d1117', border: '1px solid #333', borderRadius: 8, padding: 12, fontSize: 11, color: '#ce93d8', marginBottom: 16 }}>
141{schemaCode}
142      </pre>
143
144      <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
145        {(['safeParse', 'parse'] as const).map(m => (
146          <button key={m} onClick={() => { setMode(m); setResult(null); setFieldErrors({}); }} style={{
147            background: mode === m ? '#1565c0' : '#1a2744',
148            color: '#fff', border: `1px solid ${mode === m ? '#64ffda' : '#333'}`,
149            borderRadius: 6, padding: '6px 14px', cursor: 'pointer', fontFamily: 'monospace', fontSize: 12
150          }}>
151            schema.{m}()
152          </button>
153        ))}
154      </div>
155
156      <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
157        {(['name', 'email', 'powerLevel'] as const).map(field => (
158          <div key={field} style={{ marginBottom: 12 }}>
159            <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
160              {field}
161            </label>
162            <input
163              value={values[field]}
164              onChange={e => setValues(prev => ({ ...prev, [field]: e.target.value }))}
165              placeholder={field === 'powerLevel' ? '1-100' : field === 'email' ? 'neo@quantum.io' : 'Neural Link'}
166              style={{ ...inputStyle, borderColor: fieldErrors[field] ? '#f44336' : '#333' }}
167            />
168            {fieldErrors[field] && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{fieldErrors[field]}</div>}
169          </div>
170        ))}
171        <button type="submit" style={{
172          background: '#e91e63', color: '#fff', border: 'none', borderRadius: 6,
173          padding: '10px 20px', cursor: 'pointer', fontFamily: 'monospace', width: '100%'
174        }}>
175          schema.{mode}(formData)
176        </button>
177      </form>
178
179      {result && (
180        <div style={{
181          marginTop: 16, borderRadius: 8, padding: 12, maxWidth: 400,
182          background: result.type === 'success' ? '#1b5e20' : '#3e1010',
183          border: `1px solid ${result.type === 'success' ? '#4caf50' : '#f44336'}`
184        }}>
185          <div style={{ color: result.type === 'success' ? '#4caf50' : '#f44336', marginBottom: 4 }}>
186            {result.type === 'success' ? 'Validation successful! Typed result:' : mode === 'parse' ? 'parse() threw an exception:' : 'safeParse() returned errors:'}
187          </div>
188          <pre style={{ color: '#ccc', fontSize: 11, margin: 0, whiteSpace: 'pre-wrap' }}>{result.data}</pre>
189        </div>
190      )}
191    </div>
192  );
193}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. How does safeParse() differ from parse() in Zod?

Hands-on tasks in the game

  • Code editor

    Create a Zod schema with field validation (name, email, powerLevel) and integrate it with React Hook Form via zodResolver

  • Vertical ordering

    Arrange the steps for integrating Zod with React Hook Form in the correct order

  • Click in order

    Arrange the Zod library import syntax

  • Code editor

    Create a contact form with fields: name, email, message using controlled components and useState

Useful articles