Zod form validation in Next.js happens inside the Server Action, where the rules cannot be bypassed from the browser. Zod gives you a schema for that check: define the shape and constraints once, then parse the form fields against it.
Install Zod first.
npm install zodDefine a schema for the fields. Each key matches a form field name, and each constraint maps to a rule the value must satisfy.
// app/schema.ts
import { z } from 'zod'
export const signupSchema = z.object({
name: z.string().trim().min(2),
email: z.email(),
})The schema marks name as a trimmed string of at least two characters and email as a valid address. It says nothing about your form yet; it is only a description of the data you expect.
These examples use Zod 4, where each string format is a top-level function. On Zod 3 the same rule is written as z.string().email(), which still works in Zod 4 but is deprecated.
Now parse the submitted fields against it.
// app/actions.ts
'use server'
import { z } from 'zod'
import { signupSchema } from './schema'
export async function signUp(formData: FormData) {
const fields = { name: formData.get('name'), email: formData.get('email') }
const result = signupSchema.safeParse(fields)
if (!result.success) return { errors: z.flattenError(result.error).fieldErrors }
return { ok: true }
}The action builds an object from the form fields and runs safeParse on it. When the fields pass, result.success is true and you proceed to the write. When they fail, the action returns the field errors instead.
Read the field errors
z.flattenError turns the error into a shallow object with two keys. formErrors holds messages that belong to the whole object, and fieldErrors maps each field name to an array of message strings.
Submitting the name "A" with the email "nope" returns this:
{
formErrors: [],
fieldErrors: {
name: [ 'Too small: expected string to have >=2 characters' ],
email: [ 'Invalid email address' ]
}
}Each value is an array because one field can break several rules at once. Rendering the first entry is usually enough, and the rest stay available if you want to list them.
That shape is why Zod pairs so well with Server Actions. The action returns one object, and the form looks up the message for each input by field name without any extra parsing.
Client validation is not a guard
An HTML required attribute catches empty fields before the request leaves the browser, which saves the visitor a round trip. It does not protect the action, because the action is reachable over HTTP and can be called directly without the form.
| Check | Where it runs | Can a visitor skip it |
|---|---|---|
| required attribute | Browser | Yes |
| Zod schema | Server action | No |
A direct POST can bypass the browser entirely, so the schema is the only check that always runs. Keep the required attribute for convenience and the Zod schema for authority.
See displaying field-level validation errors from a Server Action to render those messages under each input, and reading FormData in a Server Action for the field methods.
Parse, then write
After the schema passes, use the parsed data from result.data, not the raw form fields. The schema applies its own transforms, so a trimmed name reaches the database while the original FormData stays untouched.
The types line up too. Inside the success branch, result.data is typed from the schema, so the write step gets strings rather than the string, File, or null union that formData.get returns.
A schema still only checks the shape of the input. A well formed payload can name a record the visitor does not own, so authenticate and authorize inside the action as well before the write runs.
Rune AI
Key Insights
- Install Zod and define a schema that mirrors the form fields.
- Pass the submitted fields to safeParse.
- Check result.success before writing anything.
- Return z.flattenError(result.error).fieldErrors on failure.
- Treat HTML attributes as convenience, never as the real guard.
Frequently Asked Questions
Why validate on the server at all?
What does safeParse return?
Should I keep the HTML required attribute?
Conclusion
Validate form fields inside the Server Action with a Zod schema. Run safeParse on the submitted values, and on failure return the fieldErrors from z.flattenError so the form can show a message under each field.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.