Skip to main content
Advanced Next.js · Blog 18

Next.js 16 Forms and Validation Explained

Build secure, accessible forms with Server Actions, FormData, current React pending APIs, field errors, Zod, authorization, and production-safe feedback.

Next.js 16 form moving through server validation and a Server Action to saved or field-error results

Server Actions make forms feel simple, but a production form needs much more than a submit button. It must validate untrusted values, protect sensitive operations, show pending feedback, return useful errors, and remain understandable with a keyboard or screen reader.

This tutorial extends Blog #7: Server Actions, the expected and unexpected failure model from Blog #9: Error Handling, protected operations from Blog #13: Authentication, and the request boundary from Blog #17: Proxy & Route Protection.

Repository and version context

This publishing repository is a PHP site, not an installed Next.js app. It has no root package.json, Server Actions, React form components, validation dependency, database model, or Next.js authentication provider. The downloadable starter uses an unpinned next: latest. Examples were checked against current Next.js 16 documentation, React 19 documentation, Zod 4 documentation, and stable next@16.3.3 on August 29, 2026.

Forms at a Glance

React allows a function in the form action prop. When that function is a Next.js Server Action, the browser submission sends its successful controls as a native FormData object to server code. The action parses a narrow input object, validates it, authenticates and authorizes protected work, performs the mutation, then revalidates or returns safe state.

Diagram 1: Secure form flowForm input becomes FormData, is validated on the server, passes authentication and authorization, then saves and refreshes the interface. Invalid values return field errors.
User formFormDataServer validation
Invalid → Field errorsValid → Auth → Save
Fresh UI

A Server Action is reachable through the application. Treat every argument as hostile and enforce the same validation, authentication, authorization, rate limits, and data minimization expected of any public mutation endpoint.

Your First Server Action Form

app/contact/page.tsx
export default function ContactPage() {
  async function sendMessage(formData: FormData) {
    'use server'

    const email = formData.get('email')
    const message = formData.get('message')

    // Validate before sending or storing anything.
  }

  return (
    <form action={sendMessage}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />

      <button type="submit">Send message</button>
    </form>
  )
}

An inline Server Action can live in a Server Component. A top-level 'use server' module is useful when Client Components import actions. Keep that client boundary small using the architecture from Blog #5: Server & Client Components. JavaScript is not required for the basic progressively enhanced submission when a Server Action is provided directly to a form.

Understanding FormData

Each successful named control contributes a value. get() returns the first matching FormDataEntryValue or null; the value may be a string or File. getAll() handles repeated names such as checkboxes. Never fix the type with an unchecked cast.

Parse explicitly
const emailValue = formData.get('email')
const topics = formData.getAll('topics')

if (typeof emailValue !== 'string') {
  return { errors: { email: ['Email is required.'] } }
}

const email = emailValue.trim().toLowerCase()

Object.fromEntries(formData) is convenient, but Server Action forms may include framework entries prefixed with $ACTION_. An explicit input object makes the accepted surface easier to review and prevents mass assignment.

Server-Side Validation

Server validation is authoritative because browser rules can be disabled and direct requests can be constructed. Check type, normalization, required values, length, format, allowed choices, cross-field rules, uniqueness, and current resource state as applicable.

Small form without a library
type State = { errors?: { message?: string[] } }

export async function createMessage(
  _previous: State,
  formData: FormData
): Promise<State> {
  'use server'

  const value = formData.get('message')
  if (typeof value !== 'string') {
    return { errors: { message: ['Message is required.'] } }
  }

  const message = value.trim()
  if (message.length < 10 || message.length > 2000) {
    return { errors: { message: ['Use 10 to 2,000 characters.'] } }
  }

  // Store only after every relevant check passes.
  return {}
}
Diagram 2: Client and server validationClient validation provides immediate feedback, but the server repeats authoritative validation before any mutation.
ClientRequiredTypeImmediate feedback
ServerTypes + lengthsBusiness rulesAuthoritative decision

Validation with Zod

No validation dependency exists in this repository, so nothing was installed. Zod is shown as a popular optional schema library. In an actual project, reuse its existing validator and pinned version instead of adding a second system.

The current Zod 4 API provides z.flattenError() for a flat form schema. Older examples, including some current Next.js pages, use result.error.flatten(); Zod 4 deprecates that instance method.

Zod 4 tutorial pattern
import * as z from 'zod'

const ContactSchema = z.object({
  name: z.string().trim().min(2).max(80),
  email: z.email().max(254),
  subject: z.string().trim().min(3).max(120),
  message: z.string().trim().min(10).max(2000),
})

const result = ContactSchema.safeParse({
  name: formData.get('name'),
  email: formData.get('email'),
  subject: formData.get('subject'),
  message: formData.get('message'),
})

if (!result.success) {
  const errors = z.flattenError(result.error)
  return { errors: errors.fieldErrors }
}

const data = result.data

Use only result.data after success. Schema validation does not authenticate a user, prove database ownership, prevent spam, or guarantee a unique record; those are separate checks.

Returning Field Errors

Expected input mistakes should normally return a serializable state rather than throw. Use stable field names and arrays of safe messages so the UI can place feedback next to each control. Do not return passwords, tokens, raw submitted files, SQL errors, provider payloads, or stack traces.

Serializable action state
type ContactState = {
  errors?: Partial<Record<
    'name' | 'email' | 'subject' | 'message',
    string[]
  >>
  message?: string
}

const initialState: ContactState = {}

Handling State with useActionState

Current React returns exactly three values: state, an Action dispatcher, and a pending boolean. When a Server Action is used with this hook, its signature receives the previous state first and the submitted FormData second.

app/contact/contact-form.tsx
'use client'

import { useActionState } from 'react'
import { sendContact } from './actions'

const initialState = { errors: {}, message: '' }

export function ContactForm() {
  const [state, formAction, pending] =
    useActionState(sendContact, initialState)

  return (
    <form action={formAction}>
      {/* labelled fields and linked errors */}
      <button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send message'}
      </button>
      <p aria-live="polite">{state.message}</p>
    </form>
  )
}

Older tutorials may show useFormState. Current Next.js and React guidance uses useActionState as the primary API.

Pending State with useFormStatus

useFormStatus comes from react-dom. It observes its parent form, so call it in a child rendered inside that form—not in the component that creates the same form.

SubmitButton.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Sending...' : 'Send message'}
    </button>
  )
}

React 19 also exposes data, method, and action in the status object. Use only what the interface needs and never display sensitive submitted values in pending feedback.

Diagram 3: Pending stateSubmitting changes a ready form into pending feedback with a disabled button, then resolves to success or an error state.
ReadySubmit
PendingDisable repeat clickShow status
Success or error

Disabling the button reduces accidental double-submission; it does not guarantee idempotency. Payments, orders, bookings, and other high-value actions need server-generated idempotency keys, database constraints, transaction design, or provider-specific safeguards.

Client-Side Validation

HTML attributes such as required, type="email", minLength, maxLength, and constrained choices provide fast feedback and reduce avoidable submissions. Interactive validation may help complex relationships, but it cannot enforce security because the client is controlled by the user.

RequirementClientServer
Instant feedbackExcellentSlower
Security enforcementNoYes
Database uniquenessNot reliable aloneYes
Required for trustworthy inputNoYes
UX improvementYesYes

Authentication and Authorization in Forms

A private Server Action must authenticate the current user and authorize the exact record or operation. Route protection in Proxy improves navigation but does not secure a direct action request. Use the real provider and DAL from the application; this repository has no helper to copy.

Provider-neutral architecture—not a project helper
'use server'

export async function updateProfile(formData: FormData) {
  const user = await requireCurrentUser() // use the real auth integration
  const input = validateProfile(formData)

  await updateUser(user.id, input)
}

Never accept a hidden userId, role, tenant ID, price, or permission as proof. Hidden fields are client-controlled. Load identity from the verified server session, fetch the target record, and enforce ownership, tenant, role, and resource-state policy before mutation.

Diagram 4: Form authorizationThe Server Action validates input, authenticates the user, authorizes the exact operation and record, then performs only the allowed update.
Form submit
Server ActionValidateAuthenticateAuthorize record
Allowed mutationSuccess
Form data passing through validation, authentication, authorization and server processing before a database save
A form is secure only when every gate is enforced on the server. Invalid fields return safe messages; valid input still needs current identity and permission.

Create, Update, and Delete Forms

Create

Form → validate → authenticate when private → insert.

Update

Form → load record → authorize → validate → update allowed fields.

Delete

Button/form → authenticate → authorize → delete or archive.

For edit forms, preload only safe fields and never infer ownership from a submitted record ID. For delete actions, use explicit button text, confirmation proportional to risk, and authoritative server permission. JavaScript confirmation is a UX guard, not security. Review the complete mutation architecture in Blog #7.

Redirect and Revalidate After Submission

After a successful mutation, refresh affected data with revalidatePath or the appropriate current tag API, then redirect when the flow moves to another route. Do not place redirect() inside a broad catch that treats its framework control-flow signal as an ordinary failure.

Successful create flow
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  'use server'

  const data = validatePost(formData)

  try {
    await posts.create(data)
  } catch (error) {
    // Log a sanitized unexpected failure.
    return { message: 'Unable to create the post.' }
  }

  revalidatePath('/posts')
  redirect('/posts')
}

Revalidate only what changed. Continue with Blog #8: Caching & Revalidation for path and tag semantics.

Diagram 5: Submit to fresh UIA successful submission validates, saves, revalidates affected data, and then redirects or returns an updated interface.
SubmitValidateSaveRevalidateRedirect / update UI

Optimistic Form Updates

useOptimistic temporarily displays an expected state while an Action runs. It works well for likes, small status changes, and message lists where failure can be clearly reconciled. Be cautious with payments, inventory, irreversible deletion, bookings, and financial state.

Small message-list pattern
'use client'

import { useOptimistic } from 'react'

const [optimisticMessages, addOptimistic] = useOptimistic(
  messages,
  (current, body: string) => [
    ...current,
    { id: 'pending', body, pending: true },
  ]
)

async function sendAction(formData: FormData) {
  const body = String(formData.get('message') ?? '')
  addOptimistic(body)
  await sendMessage(body)
}

The optimistic setter runs inside the form Action context; outside an Action it must be coordinated through a Transition. The server remains authoritative and the UI must explain or revert failures.

Accessible Forms

Every control needs an accessible name, usually a visible label. Placeholder text is not a label. Keep a visible focus indicator, logical keyboard order, usable touch targets, and instructions that do not rely only on color.

Linked accessible field error
<label htmlFor="email">Email</label>
<input
  id="email"
  name="email"
  type="email"
  aria-invalid={Boolean(state.errors?.email)}
  aria-describedby={state.errors?.email ? 'email-error' : undefined}
/>
{state.errors?.email && (
  <p id="email-error" role="alert">
    {state.errors.email[0]}
  </p>
)}

For a larger form, place a concise error summary near the top, move focus deliberately after submission, and link summary entries to invalid fields. Avoid announcing every keystroke. Use an aria-live="polite" region for important submission results.

Accessible form field with a linked visible error followed by pending and success states
Errors need structure, not just red color. Connect the visible message to the field, expose invalid state, preserve focus, and communicate pending and final outcomes.
Diagram 6: Accessible error flowAn invalid field receives a visible understandable message linked with aria-describedby and exposes aria-invalid to assistive technology.
Invalid field
Visible messagearia-describedbyaria-invalidText, not color alone
User corrects field

Password Forms

Never log, return, place in query strings, or unnecessarily repopulate passwords after errors. Follow the actual authentication provider for hashing, password policy, breach protection, reset flows, and rate limiting. Store provider and email credentials using the server-only practices from Blog #16.

Forms with File Uploads

FormData can contain File objects. Enforce server-side size limits, inspect content rather than trusting an extension or client-reported MIME type, generate safe storage names, keep uploads outside executable public paths, restrict access, and scan for malware when the risk requires it. A complete upload pipeline also needs storage quotas, timeout and memory controls, cleanup of incomplete objects, and safe download headers.

Build a Production-Ready Contact Form

A practical contact form collects name, email, subject, and message. The Server Action parses an explicit object, applies length and format rules, introduces abuse controls appropriate to traffic, sends or stores through a server-only provider, and returns safe field or form state.

1. Keep the action contract narrow

Define one serializable state shared by the action and client form. Do not return the submitted message or email address simply to rebuild the interface; that can unnecessarily duplicate private data in the response. Preserve only non-sensitive values deliberately when the product requires it.

app/contact/actions.ts
'use server'

import * as z from 'zod'

type ContactState = {
  ok: boolean
  message: string
  errors: Record<string, string[] | undefined>
}

const schema = z.object({
  name: z.string().trim().min(2).max(80),
  email: z.email().max(254),
  subject: z.string().trim().min(3).max(120),
  message: z.string().trim().min(10).max(2000),
})

export async function sendContact(
  _previous: ContactState,
  formData: FormData
): Promise<ContactState> {
  const result = schema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    subject: formData.get('subject'),
    message: formData.get('message'),
  })

  if (!result.success) {
    return {
      ok: false,
      message: 'Check the highlighted fields.',
      errors: z.flattenError(result.error).fieldErrors,
    }
  }

  // Apply abuse controls and call a server-only mail or storage module.
  // Never build an HTML email by concatenating raw user values.
  await contactService.deliver(result.data)

  return { ok: true, message: 'Message sent.', errors: {} }
}

The example assumes a Zod 4 project and an existing server-only contactService; neither exists in this PHP publishing repository. A real service must read credentials from protected environment variables, escape values for the destination context, enforce provider limits, and log only safe operational metadata.

2. Render fields and state accessibly

The Client Component owns only interaction state. The Server Action still owns trust. Render every field with a persistent label, keep stable error IDs, and prevent the pending button from becoming an ambiguous unlabeled spinner.

app/contact/contact-form.tsx
'use client'

import { useActionState, useEffect, useRef } from 'react'
import { sendContact } from './actions'

const initialState = { ok: false, message: '', errors: {} }

export function ContactForm() {
  const [state, formAction, pending] =
    useActionState(sendContact, initialState)
  const formRef = useRef<HTMLFormElement>(null)

  useEffect(() => {
    if (state.ok) formRef.current?.reset()
  }, [state.ok])

  return (
    <form ref={formRef} action={formAction} noValidate>
      <label htmlFor="contact-name">Name</label>
      <input
        id="contact-name"
        name="name"
        maxLength={80}
        aria-invalid={Boolean(state.errors.name)}
        aria-describedby={state.errors.name ? 'name-error' : undefined}
      />
      {state.errors.name && (
        <p id="name-error" role="alert">{state.errors.name[0]}</p>
      )}

      {/* Repeat the same labelled pattern for email, subject, and message. */}
      <button type="submit" disabled={pending}>
        {pending ? 'Sending message...' : 'Send message'}
      </button>
      <p aria-live="polite">{state.message}</p>
    </form>
  )
}

noValidate is optional. This example uses it so one consistent server-returned message system can be demonstrated; leaving native browser validation enabled is often a good progressive baseline. If the form resets after success, ensure the success message stays visible long enough and focus does not unexpectedly jump. Never reset after a validation or server error, because that would erase the user's work.

Diagram 7: Contact form architectureThe contact form submits to a Server Action that parses, validates, rate-limits where appropriate, stores or sends safely, and returns a success or error state.
Contact form
Server ActionParse FormDataValidate lengthsAbuse checkStore / send
Safe status UI

Public forms may need layered rate limiting, a honeypot, CAPTCHA when justified, email verification, and backend abuse monitoring. None is perfect alone. Never insert untrusted name, subject, or message values as raw HTML into an email, dashboard, or page; encode or sanitize for the output context.

Error typeExampleHandling
ValidationBad emailLinked field message
AuthenticationSession missingSafe login flow
AuthorizationNo permissionDeny safely
ConflictDuplicate usernameUseful form message
UnexpectedDatabase failureGeneric message plus sanitized logging

Expected validation and conflict errors normally return state. Unexpected failures should reach the application's safe logging and error-boundary strategy without revealing SQL, stack traces, secrets, or provider internals.

Diagram 8: Validation state flowA form begins ready, becomes pending, and resolves to field errors, a safe form error, or success with updated data.
ReadyPending
Field errorsSafe form errorSuccess

Common Next.js Form Mistakes

Client validation only

Repeat every trustworthy rule on the server.

Trusting FormData types

Values are strings or files and can be absent or manipulated.

Trusting hidden inputs

Identity, role, ownership, and price require server truth.

Authentication without authorization

Verify the exact resource and operation.

Generic field feedback

Return safe field-level messages for expected mistakes.

Throwing normal validation errors

Return serializable state instead.

Using useFormState

Use current useActionState as the primary API.

No pending feedback

Show status and reduce repeat clicks.

Button-only duplicate protection

Critical actions need server idempotency.

Catching redirect broadly

Do not swallow framework control flow.

Logging form data

Submissions can contain passwords and private information.

No length limits

Bound text and files to reduce abuse and cost.

Trusting file metadata

Inspect and store uploads defensively.

Placeholder-only labels

Use persistent visible labels.

Color-only errors

Add understandable text and accessible relationships.

Next.js 16 Forms & Validation Best Practices

  • Use Server Actions for suitable same-application mutations.
  • Validate all inputs server-side; use client validation for UX.
  • Reuse the installed validation library and keep schemas near trusted boundaries.
  • Return safe field-level errors and use current useActionState.
  • Use useFormStatus for a child submit component when appropriate.
  • Keep Client Components small and field names consistent.
  • Authenticate protected mutations and authorize the exact record.
  • Never trust hidden identity, role, ownership, tenant, or pricing values.
  • Revalidate only affected data and redirect only after success.
  • Never log passwords, secret tokens, or complete private submissions.
  • Use labels, linked errors, focus management, and text status.
  • Limit lengths and upload sizes, and protect public forms from abuse.
  • Follow version-matched Next.js, React, auth-provider, and validator documentation.

Frequently Asked Questions

How do forms work in Next.js 16?

React extends the form action prop so it can receive a Server Action. On submission, the function runs on the server and receives the submitted FormData object.

How do I submit a form with a Server Action?

Create a server function with use server, then pass it to form action. Match every control name with the FormData keys the action reads.

How do I read FormData in Next.js?

Use formData.get for one field, getAll for repeated names, or Object.fromEntries for a broad object. Validate every value because FormData values are strings or files and remain untrusted.

Should I validate forms on the client or server?

Use both for different jobs. Client and HTML validation improve immediate feedback; server validation is authoritative and must run before protected work.

How do I use Zod with Next.js Server Actions?

Build a schema, pass an explicit object of FormData values to safeParse, return field errors on failure, and use result.data only after success. Install and pin Zod only when it fits the real project.

What is useActionState?

useActionState is a React Hook that connects an Action result to component state. It returns the current state, an Action dispatcher suitable for the form action prop, and an isPending boolean.

What is useFormStatus?

useFormStatus is imported from react-dom and reports the submission status of its parent form. The component calling it must be rendered inside that form.

How do I show pending state while a form submits?

Use the pending value from useActionState or render a child submit component that reads pending from useFormStatus. Keep the status text understandable and announce important results.

How do I return field errors from a Server Action?

Return a serializable state object that maps field names to arrays of safe messages. Render each message beside its control and connect it with aria-describedby and aria-invalid.

How do I redirect after form submission?

Call redirect after successful validation and mutation. Keep it outside a broad catch block because redirect uses framework control flow that must not be swallowed.

How do I revalidate data after a form mutation?

After the mutation succeeds, call revalidatePath for the affected route or use the appropriate current tag API, then redirect or return state according to the desired UI flow.

Do protected forms need authentication?

Yes when the operation is private. The Server Action must authenticate the current user and authorize the exact record or operation even when the page route was already protected.

Can I trust hidden form fields?

No. Users can change hidden inputs. Use server-authenticated identity and load protected records before checking ownership or permission.

How do I prevent duplicate form submissions?

Pending UI and a disabled button reduce accidental repeats. Payments, orders, bookings, and other valuable operations also need server-side idempotency or uniqueness safeguards.

How do I make Next.js forms accessible?

Use visible labels, keyboard-operable controls, clear focus, text error messages, aria-describedby, aria-invalid, useful pending feedback, and an error summary for longer forms.

Next Steps

You can now build forms that parse input deliberately, validate on the server, return accessible field errors, expose pending state, enforce identity and permission, refresh affected data, and handle failures safely. Return to Blog #17: Next.js 16 Proxy Explained for route-level request checks.

Coming next: Next.js 16 PostgreSQL Integration Guide. Blog #19 is planned, so this page does not create a broken URL.

Official references

WhatsApp