Handling Forms in Next.js: Client & Server-Side Validation
Forms are where a huge portion of real user interaction happens — sign-ups, checkouts, contact forms, settings pages — and they're also where a huge portion of bugs and security issues originate if handled carelessly. A genuinely well-built form does three things correctly: it tracks what the user has typed, it gives immediate, helpful feedback when something is invalid, and it validates the data again on the server, since client-side validation alone can always be bypassed by a determined or malicious user.
This lesson covers building a form's interactive state with useState, then introduces schema-based validation using Zod and Yup — two popular libraries that let you define a single, reusable schema describing exactly what valid data looks like, which can then validate both on the client for instant feedback and on the server for genuine security, without duplicating your validation rules in two different places.
Learning Objectives
- Track form input values and state using useState in a Client Component.
- Understand why server-side validation is essential even with client-side validation in place.
- Define a validation schema using Zod or Yup.
- Validate form data on the client for immediate user feedback.
- Validate the same data again on the server before processing it.
Core Definitions
- Controlled input: A form input whose value is driven entirely by React state, updated via an onChange handler, rather than managed internally by the DOM.
- Client-side validation: Checking form data for correctness in the browser, typically before submission, to give users immediate feedback.
- Server-side validation: Re-checking form data on the server after submission, which is essential because client-side checks can be bypassed or skipped entirely by a malicious user.
- Schema validation: Defining the exact expected shape, types, and constraints of data as a reusable schema object, then validating actual data against that schema.
- Zod: A TypeScript-first schema validation library that lets you define a schema and automatically infers a matching TypeScript type from it.
- Yup: A popular JavaScript schema validation library with a similarly fluent, chainable API to Zod, widely used in both React and Node.js projects.
Detailed Explanation
The foundation of any interactive form is tracking its current values in state. Using useState (from Lesson 1.4's React fundamentals), each input's value is stored in a piece of state and kept in sync via an onChange handler — this is called a controlled input, since React, not the browser's default DOM behavior, is the single source of truth for what the input currently displays. This pattern is what enables things like showing a live character count, disabling a submit button until all required fields are filled, or instantly clearing an error message the moment a user starts correcting a mistake.
Client-side validation checking whether an email field contains an '@' symbol, or whether a password meets a minimum length, gives users immediate, helpful feedback without waiting for a round trip to the server — a meaningfully better experience. But here's the critical security principle this lesson centers on: client-side validation can ALWAYS be bypassed. A user could disable JavaScript, use browser developer tools to directly submit a request, or call your API endpoint directly with a tool completely outside your form's UI. This means client-side validation is purely a UX enhancement — it must never be treated as your application's actual security or data-integrity boundary. Every piece of data your server receives must be independently re-validated on the server, no matter how thoroughly it was already checked on the client.
This is exactly the problem schema validation libraries like Zod and Yup solve elegantly: rather than writing separate, duplicated validation logic on the client and the server, you define a single schema — a structured description of exactly what valid data looks like — and reuse that same schema to validate on both sides. A Zod schema like `z.object({ email: z.string().email(), age: z.number().min(18) })` describes precisely what a valid submission must contain, and calling `.parse()` or `.safeParse()` against actual form data either succeeds or returns detailed, specific error messages about exactly what's wrong and where.
Zod has a particularly strong advantage in TypeScript projects: it can automatically infer a matching TypeScript type directly from your schema using `z.infer<typeof mySchema>`, meaning your validation logic and your type definitions can never silently drift out of sync with each other — they're generated from the exact same single source of truth. Yup offers a very similar chainable API and is widely used as well, particularly in projects that adopted it before Zod's TypeScript-first approach became popular, and both integrate well with popular form libraries.
The recommended real-world pattern: use the same Zod (or Yup) schema on the client, calling it on form submission (or even on individual field blur) to show immediate validation errors, AND on the server — inside a Server Action or API route — re-validating that exact same schema against the incoming data before it's ever saved to a database or used for any sensitive operation, ensuring your application's actual data integrity and security never depends on trusting the client.
Diagram Description
Visualize the shared-schema validation flow:
[schemas/signupSchema.ts] — ONE Zod schema, defined once
│
├──> [Client Component form] — validates on submit for instant UI feedback
│ (shows: 'Email is invalid' next to the field)
│
└──> [Server Action / API route] — re-validates the SAME schema
(rejects the request entirely if invalid, regardless of client checks)
A malicious/bypassed client sending bad data directly to the server:
[Raw fetch() call, skipping the form UI entirely] --> [Server Action still re-validates] --> [Rejected: 'Invalid email']
Comparison Table
| Validation Layer | Purpose | Can Be Bypassed? | Should Be Trusted for Security? |
|---|---|---|---|
| Client-side (in the browser) | Instant user feedback, better UX | Yes — always | No |
| Server-side (Server Action / API route) | Actual data integrity and security enforcement | No | Yes |
Next.js Practical Example
// schemas/signupSchema.ts — ONE schema, shared by client and server
import { z } from 'zod';
export const signupSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export type SignupInput = z.infer<typeof signupSchema>; // auto-generated TS type
// components/SignupForm.tsx — client-side usage for instant feedback
'use client';
import { useState } from 'react';
import { signupSchema } from '@/schemas/signupSchema';
export default function SignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const result = signupSchema.safeParse({ email, password });
if (!result.success) {
const fieldErrors: Record<string, string> = {};
result.error.issues.forEach((issue) => {
fieldErrors[issue.path[0] as string] = issue.message;
});
setErrors(fieldErrors);
return;
}
setErrors({});
// proceed to submit result.data to the server
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{errors.email && <p>{errors.email}</p>}
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{errors.password && <p>{errors.password}</p>}
<button type="submit">Sign Up</button>
</form>
);
}
// app/actions/signup.ts — SAME schema, re-validated on the server
'use server';
import { signupSchema } from '@/schemas/signupSchema';
export async function signupAction(rawData: unknown) {
const result = signupSchema.safeParse(rawData); // never trust client input
if (!result.success) {
return { success: false, errors: result.error.issues };
}
// safe to use result.data — it's guaranteed valid at this point
return { success: true };
}
signupSchema is defined exactly once, in a shared file that both the client component and the server action import — this is the key pattern eliminating duplicated validation logic. SignupForm calls signupSchema.safeParse() on submit purely to give the user instant, specific error messages next to each field without waiting for a server round trip, improving the experience. Critically, signupAction independently calls signupSchema.safeParse() again on the server, on the exact same schema, before treating the data as safe to use — this means even if a malicious actor bypassed the form entirely and called signupAction directly with garbage data, the server-side validation would still correctly reject it, since the application's actual security boundary never depends on the client having behaved correctly.
Industry Examples
- E-commerce checkout forms validate shipping addresses and payment details on the client for instant feedback, while independently re-validating on the server before charging a customer's card, since payment processing absolutely cannot depend on trusting client-supplied data.
- SaaS sign-up forms use Zod schemas shared between the client form and a Server Action, ensuring the exact same password strength and email format rules are enforced consistently on both sides without duplicated logic.
- Job application platforms validate resume upload forms and multi-step application forms with Yup, giving applicants immediate feedback about missing required fields before they even attempt to submit.
- Banking and fintech applications treat server-side validation as non-negotiable for any form touching money movement, even when client-side validation using Zod or Yup already provides a polished, immediate feedback experience.
- Multi-tenant SaaS platforms use shared Zod schemas to validate API request bodies consistently across many different Server Actions and API routes, reducing the chance of an unvalidated endpoint slipping through.
Interview Questions and Answers
Q1. Why is client-side form validation alone never sufficient for security?
Client-side validation runs in the user's browser and can always be bypassed — by disabling JavaScript, using developer tools, or calling an API endpoint directly outside the form's UI. It should be treated purely as a UX enhancement for immediate feedback, never as the application's actual data integrity or security boundary.
Q2. What is a controlled input in React, and how does it relate to useState?
A controlled input is a form input whose displayed value is driven entirely by React state rather than managed internally by the DOM. Its value is set from a state variable, and an onChange handler updates that state on every keystroke, keeping React as the single source of truth for the input's current value.
Q3. What problem does defining a shared Zod or Yup schema solve for client and server validation?
It avoids duplicating validation logic in two separate places (client and server) that could drift out of sync over time. A single schema, imported by both the client form and the server-side handler, guarantees identical validation rules are enforced consistently on both sides.
Q4. What is a key TypeScript-specific advantage Zod offers over some other validation libraries?
Zod can automatically infer a matching TypeScript type directly from a schema using z.infer<typeof schema>, meaning the validation logic and the corresponding type definition are generated from a single source of truth and can never silently drift apart.
Q5. Where should the authoritative, security-critical validation of form data always happen?
On the server, inside a Server Action or API route, immediately before the data is used for anything sensitive like saving to a database or processing a payment — regardless of whatever client-side validation already occurred, since server-side validation cannot be bypassed by the client.
MCQs With Answers
1. Why can client-side form validation never be treated as a security boundary?
- It's too slow for real use
- It can always be bypassed by a user skipping or disabling it
- It doesn't support error messages
- It only works with Zod, not Yup
Answer: B. It can always be bypassed by a user skipping or disabling it
Explanation: Since client-side validation runs in the user's own browser, it can be bypassed entirely, meaning it must never be relied upon as the application's actual security enforcement.
2. What is a controlled input in React?
- An input styled with CSS
- An input whose value is driven by React state via onChange
- An input that cannot be edited
- An input validated only by the browser
Answer: B. An input whose value is driven by React state via onChange
Explanation: A controlled input keeps its displayed value synced with a React state variable, updated through an onChange handler, making React the source of truth.
3. What is the main benefit of using the same Zod schema on both the client and server?
- It makes the app run faster
- It ensures identical validation rules without duplicated logic
- It removes the need for a database
- It automatically styles form inputs
Answer: B. It ensures identical validation rules without duplicated logic
Explanation: Sharing one schema between client and server validation guarantees consistent rules and avoids the risk of the two diverging over time.
4. What does z.infer<typeof mySchema> do in a TypeScript project using Zod?
- Validates form data at runtime
- Automatically generates a matching TypeScript type from the schema
- Converts the schema to JSON
- Sends the schema to the server
Answer: B. Automatically generates a matching TypeScript type from the schema
Explanation: Zod can infer a TypeScript type directly from a schema definition, keeping validation logic and type definitions in sync from a single source of truth.
5. Where must form data ultimately be validated for genuine security, regardless of client-side checks?
- Only in the browser's console
- On the server, before the data is used for anything sensitive
- Nowhere, if client validation already passed
- Only in the database schema
Answer: B. On the server, before the data is used for anything sensitive
Explanation: Server-side validation is the only validation layer that cannot be bypassed by the client, making it essential for genuine data integrity and security.
Common Mistakes to Avoid
- Relying solely on client-side validation and skipping server-side re-validation, creating a serious security and data-integrity gap.
- Writing separate, duplicated validation logic on the client and server instead of sharing a single schema between them.
- Forgetting to call preventDefault() on a form's submit event, causing an unwanted full page reload on submission.
- Not showing specific, per-field error messages, instead displaying a single generic 'something went wrong' message that doesn't help users fix their input.
- Trusting result.data from a schema parse without actually checking result.success first, potentially proceeding with invalid or undefined data.
Interview Notes
- Controlled inputs keep their value synced with React state via useState and an onChange handler.
- Client-side validation is purely a UX enhancement and can always be bypassed; it is never a security boundary.
- Server-side validation, performed independently of any client-side checks, is essential for actual data integrity and security.
- Zod and Yup allow defining a single, reusable schema for validating data, avoiding duplicated logic between client and server.
- Zod's z.infer can automatically generate a matching TypeScript type directly from a schema definition.
Key Takeaways
- A well-built form gives users immediate feedback on the client while never trusting that feedback as the application's actual security guarantee.
- Sharing a single Zod or Yup schema between client and server eliminates duplicated, potentially inconsistent validation logic.
- Server-side validation is non-negotiable for any form whose data touches something sensitive — payments, authentication, or persisted records.
- Zod's TypeScript type inference keeps validation rules and type definitions permanently in sync, reducing an entire class of subtle bugs.
Summary
Handling forms correctly in Next.js involves tracking input state with useState via controlled inputs, then validating that data both on the client and, critically, on the server. Client-side validation, while valuable for immediate, helpful user feedback, can always be bypassed and must never be treated as an application's actual security boundary. Schema validation libraries like Zod and Yup solve this cleanly by letting you define a single, reusable schema describing exactly what valid data looks like, which can then be imported and used to validate data both in the client form (for instant feedback) and inside a Server Action or API route (for genuine, unbypassable security enforcement) — without duplicating validation rules in two separate places. Zod additionally offers strong TypeScript integration, automatically inferring a matching type directly from a schema, keeping validation logic and type definitions permanently synchronized.