Next.js course Β· Module 6: Forms and Server Actions
Form Validation with Zod
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:
- TypeScript-first - Zod schemas automatically generate types, eliminating duplication
- Zero dependencies - lightweight library (~10 KB gzipped)
- Next.js standard - officially recommended in Next.js documentation
- Works on server and client - ideal for Server Actions
Installation and Import
1npm install zod1import { 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 schemasparse()andsafeParse()- data validation.min(),.email(),.regex()- rules with error messages.refine()- custom validationzodResolver- integration with React Hook Formz.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. 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