Forms with Server Actions
The full flow, values kept after an error, and RHF vs Conform vs TanStack Form.
Updated
What a form with a Server Action means
In Next.js, the most direct way to submit a form is a Server Action: the form sends its FormData straight to a function on the server, with no API route and no hand-written fetch. This lesson puts together everything you've learned: a form → Zod validation on the server → errors back to the UI → cache invalidation.
The full flow
If the data is invalid, the action returns the errors and the form shows them:
formFormDataaction→Server ActionsafeParse with Zodinvalid→UIper-field errors + the values keptIf it's valid, the action saves and refreshes the data:
Server Actionvalid datasaves→DBupdateTag / redirect→UIfresh dataThe implementation
// features/signup/api/signup-action.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { signupSchema } from '../model/signup-schema'
export type SignupState = {
errors?: Partial<Record<keyof z.infer<typeof signupSchema>, string[]>>
values?: Record<string, string>
message?: string
}
export async function signup(prev: SignupState, formData: FormData): Promise<SignupState> {
const raw = Object.fromEntries(formData) as Record<string, string>
const result = signupSchema.safeParse(raw)
if (!result.success) return { errors: z.flattenError(result.error).fieldErrors, values: raw }
const exists = await db.user.findUnique({ where: { email: result.data.email } })
if (exists) return { errors: { email: ['An account with this email already exists'] }, values: raw }
await db.user.create({ data: result.data })
redirect('/welcome')
}// features/signup/ui/signup-form.tsx
'use client'
import { useActionState } from 'react'
import { signup } from '../api/signup-action'
export function SignupForm() {
const [state, action, pending] = useActionState(signup, {})
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" defaultValue={state.values?.email} aria-invalid={!!state.errors?.email} aria-describedby="email-error" />
<p id="email-error" role="alert">{state.errors?.email?.[0]}</p>
<button disabled={pending}>Create account</button>
</form>
)
}The details that matter:
| Detail | Why |
|---|---|
you return values and put them in defaultValue |
after an error, React resets the form — without this the user loses what they typed |
| business errors (an existing email) in the same format | the UI shows them just like validation errors |
redirect / updateTag only on success |
read-your-own-writes — see Caching |
| auth + authorization at the start of the action | the action is a public endpoint |
Validating on the client too — the options
The flow above validates only on the server: correct and safe, but the user sees the errors only after submitting. For instant feedback:
| Approach | How | Pros | Cons |
|---|---|---|---|
just useActionState |
as above, + HTML attributes (required, type) |
simple, works without JS | errors only on submit |
| React Hook Form + a Server Action | RHF validates with zodResolver, then you call the action with the data |
excellent UX, a big ecosystem | two mental models, doesn't work without JS |
| Conform | built specifically for Server Actions: the same schema, validation on the client and the server, works without JS | real progressive enhancement | a smaller community |
| TanStack Form | a new, fully typed, framework-agnostic library | very good types, fast | more verbose, newer |
| Formik | the "classic" library | lots of old code uses it | poorly maintained — not for new projects |
How to choose
- A simple form (login, contact, a newsletter) →
useActionState+ Zod on the server. - A complex form (many fields, dynamic lists, live validation) → React Hook Form + Zod, with the same schema in the Server Action.
- You want full progressive enhancement and validation on both sides → Conform.
Whatever you choose: there's a single Zod schema, and the server always validates.
Summary
- Form → Server Action →
safeParse→ errors + values back, or save +updateTag/redirect. - Keep the values after an error; business errors in the same format.
- Simple →
useActionState; complex → React Hook Form; progressive enhancement → Conform.