Forms & validation
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: Capturing user input safely — and showing them helpful errors when they get it wrong. The most-used and most-error-prone feature of almost every webapp.
In plain English
A form is anywhere a user types or selects data and submits it: login, signup, search, contact, settings, checkout, comments. Every webapp has forms. The user-facing form is just HTML inputs wrapped in a <form> element — but what happens around the form is where the engineering lives:
- Validation — checking that input is acceptable before doing anything with it
- Submission — sending the data somewhere (an API, a server action, an email)
- Error display — telling the user what went wrong, kindly and clearly
- Loading states — showing the user that something is happening
- Success states — confirming it worked
- Accessibility — making it usable with keyboard and screen reader
In a modern Next.js app, forms have a great new option: Server Actions. Instead of building an API endpoint and fetching to it from a client component, you write a function that runs on the server and pass it directly to <form action={...}>. Less code, less ceremony, easier to reason about.
Why it matters
- Validation is where security vulnerabilities live. SQL injection, XSS, command injection — all start with insufficient validation of user input.
- Bad form UX is one of the top reasons users abandon webapps. “Tell me what I did wrong” beats “Generic error” every time.
- Forms touch every system. Auth, payments, search, search, settings — all forms. Getting forms right pays back across the whole app.
- The patterns differ from what they were in 2018. React Hook Form + Zod + Server Actions are the modern stack. Don’t follow tutorials from years ago.
The two sides of validation
You must validate input in two places:
1. Client-side (for UX)
Validation in the browser as the user types or before submitting. Gives instant feedback: “Email looks wrong,” “Password too short.”
Important: This is for UX only. Never trust it for security. A malicious user can bypass any client-side check.
2. Server-side (for security)
Validation when the form data hits the server. Re-checks everything. This is the trust boundary; nothing the client says can be assumed.
Always validate server-side. Optionally also validate client-side for better UX.
A well-architected setup uses the same validation schema on both sides. Zod is the modern way to do this in TypeScript — define the schema once; use it in the browser and on the server.
The modern stack
For a Next.js app in 2026:
| Concern | Tool |
|---|---|
| Schema definition | Zod |
| Client form state | React Hook Form (often) — or just plain controlled inputs |
| UI components | shadcn/ui Form (built on react-hook-form + zod) |
| Submission | Server Actions — or fetch to a Route Handler |
| Error display | Built into shadcn Form |
You don’t need every piece. Plain controlled inputs work for simple forms. Server Actions work without React Hook Form. Pick what fits the complexity.
A concrete example: a sign-up form, three ways
Way 1: Plainest possible (controlled inputs + Server Action)
// app/signup/page.tsx
async function signup(formData: FormData) {
"use server";
const email = formData.get("email") as string;
const password = formData.get("password") as string;
// Server-side validation
if (!email.includes("@")) {
return { error: "Invalid email" };
}
if (password.length < 8) {
return { error: "Password must be 8+ characters" };
}
await createUser({ email, password });
redirect("/dashboard");
}
export default function SignupPage() {
return (
<form action={signup}>
<label>Email <input name="email" type="email" required /></label>
<label>Password <input name="password" type="password" required /></label>
<button type="submit">Sign up</button>
</form>
);
}Tight, no client JavaScript at all. The form submits to signup directly — Next.js handles the wiring. HTML’s built-in attributes (type="email", required) provide basic client-side validation.
Way 2: With Zod for validation + structured errors
// lib/signupSchema.ts — shared between client and server
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters")
});// app/signup/page.tsx
import { signupSchema } from "@/lib/signupSchema";
async function signup(formData: FormData) {
"use server";
const parsed = signupSchema.safeParse({
email: formData.get("email"),
password: formData.get("password")
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
await createUser(parsed.data);
redirect("/dashboard");
}Same shape; the validation is now declarative and structured. Errors come back as per-field arrays.
Way 3: With React Hook Form + shadcn/ui (richest UX)
For a form with many fields, inline error display, optimistic UI, etc.:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema } from "@/lib/signupSchema";
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export function SignupForm() {
const form = useForm({
resolver: zodResolver(signupSchema),
defaultValues: { email: "", password: "" }
});
const onSubmit = async (data) => {
const result = await signupAction(data);
if (result.error) form.setError("email", { message: result.error });
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* password field similarly */}
<Button type="submit">Sign up</Button>
</form>
</Form>
);
}Heavier, but: inline errors update as the user types, all accessibility is handled, the form state is managed cleanly.
Validation strategies (when to check)
When during the lifecycle to validate matters:
| Strategy | Pros | Cons |
|---|---|---|
| On submit only | Doesn’t nag user while typing | User finds out about errors only at end |
| On blur (leave a field) | Mid-friendly; doesn’t interrupt typing | Errors appear when user might still be thinking |
| On change (each keystroke) | Instant feedback | Annoying if it scolds while typing |
| On change after first invalid submit (recommended) | Quiet until user has tried; helpful after | More complex to implement |
React Hook Form supports all these via the mode option. “On change after first invalid submit” (mode: "onTouched" or "onBlur" until submit) is usually the best balance.
Required input attributes
Native HTML provides surprising amounts of validation for free:
<input type="email" required> <!-- must be email format -->
<input type="url"> <!-- must look like a URL -->
<input type="number" min="0" max="100"> <!-- numeric, in range -->
<input type="date" min="2024-01-01"> <!-- valid date, in range -->
<input pattern="\d{4}" title="4 digits">
<input minlength="3" maxlength="20">
<input type="password" autocomplete="new-password">These trigger browser-native validation messages on submit and are accessible by default. Pair with custom validation for richer error messages.
Common form patterns
Search forms
Often submit on Enter without an explicit button. Consider live search (with debouncing) instead.
Multi-step forms
Wizard-style flows for long forms (signup with profile, checkout, etc.). State management gets more complex. Save partial progress.
File uploads
<input type="file">. Server Actions in Next.js handle FormData with files naturally.
Combo boxes / autocomplete
shadcn/ui’s Combobox is the modern answer. Use for selecting from large lists.
Conditional fields
Show field B only if field A is “Other,” etc. Standard React state.
Forms in modals
Make sure focus moves into the modal on open and back on close. shadcn handles this.
Loading and success states
While the form is submitting:
- Disable the submit button
- Optionally show a spinner
- Don’t allow double-submits
In a Server Action, useFormStatus gives you a pending state for client components. For plain forms, manage with useState.
On success:
- Redirect, or
- Show a success message, then clear or redirect
- Consider focusing the success message for screen reader users
Error messages — write them well
Bad: “Invalid input” Better: “Email is required” Best: “Email is required so we can send you a confirmation”
Three principles:
- Tell the user what was wrong (“Password must be at least 8 characters”)
- Tell them how to fix it (“Add 3 more characters”)
- Don’t blame them (“Looks like the email format doesn’t match — should be something like name@example.com”)
Inline errors next to the field, not in a wall of red at the top of the form. shadcn/ui’s FormMessage does this by default.
Common gotchas
-
Trusting client-side validation. A malicious user removes the check, sends bad data. Always validate server-side.
-
Returning detailed error messages to attackers. “Email already in use” vs “Account exists” — the first leaks user data. For signup specifically, consider not revealing existence.
-
Not handling concurrent submissions. User clicks submit twice → two requests. Disable the button on submit; idempotency on server.
-
Missing CSRF protection. Plain forms in older patterns need CSRF tokens. Server Actions in Next.js have built-in CSRF protection. Custom Route Handlers may need it.
-
Storing passwords in plain text. Never. Always hash with bcrypt/argon2. See Passwords & hashing.
-
Not normalizing email. “George@example.com” and “george@example.com” might be the same user. Normalize before storage / comparison.
-
Not trimming whitespace. Users paste ” george@example.com ” with whitespace. Trim before validation.
-
Placeholder as label. Loses context when typing, fails accessibility. Use actual labels.
-
No
autocompletehints. Browsers can autofill if you tell them what each field is for.autocomplete="email","current-password","new-password","name","street-address"etc. Big UX win. -
Re-rendering the entire form on every keystroke. With controlled inputs and naive setup, can become slow on big forms. React Hook Form uses uncontrolled inputs to avoid this.
-
Forms that don’t submit on Enter. A form with only one button doesn’t always submit on Enter unless that button is
type="submit". Inside a form,<button>defaults to type=“submit” — but verify if your design has multiple buttons. -
Files in Server Actions.
formData.get("file")returns aFileobject. Streaming large files needs different handling. -
Numeric inputs return strings.
formData.get("age")is a string even fromtype="number". Parse before using. -
Date input formats.
type="date"returns ISO format (“2024-03-15”). Don’t assume local format. -
requiredon radio groups. Userequiredon each radio with the samename, or use form-level validation. -
Long forms without saved progress. User navigates away, loses everything. Save to localStorage or to drafts.
-
Submitting on field blur. Sometimes intended (settings page where each field auto-saves), often not. Be explicit about which fields auto-save.
-
Forgetting accessibility on custom inputs. A “custom dropdown” without keyboard support is broken. Use shadcn or Radix primitives.
-
Sending the entire form object to the server when only one field changed. For settings pages, send only the diff. For login, send all.
See also
- HTML 🟩 —
<form>,<input>, native validation attributes - JavaScript 🟩 — event handling
- TypeScript 🟩 — Zod schemas are TypeScript-typed
- React 🟩 — controlled inputs, useState
- Next.js 🟩 🟦 — Server Actions for forms
- ui 🟩 — Form component
- Accessibility (a11y) 🟩 — labels, error announcement
- Authentication vs authorization 🟥 — auth forms
- Passwords & hashing 🟥
- XSS 🟥 — risks from un-sanitized input
- SQL injection 🟥 — risks from un-validated input
- How-to: Add Supabase auth 🟩 — real form implementation
- Glossary: JSON