webroad.online
  1. 1Web
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Tooling
  8. 8React
  9. 9State management
  10. 10Next.js
  11. 11Forms
  12. 12Data and backend
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Animations
  16. 16Testing
  17. 17Architecture
Forms · Lesson 4 of 4

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 kept

If it's valid, the action saves and refreshes the data:

Server Actionvalid datasaves→DBupdateTag / redirect→UIfresh data

The 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

  1. A simple form (login, contact, a newsletter) → useActionState + Zod on the server.
  2. A complex form (many fields, dynamic lists, live validation) → React Hook Form + Zod, with the same schema in the Server Action.
  3. 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.

Official sources

Exercises

Was this page helpful?

One tap — no account needed.