Displaying Field-Level Validation Errors from a Server Action

Return fieldErrors from a Server Action and render each message under its input with aria-describedby, so errors are visible and announced.

6 min read

Field-level validation errors show a message under the exact input that failed, instead of one generic banner. In a Server Action form, you return a fieldErrors object from the action and render each array entry next to its input.

The action validates and returns an object keyed by field name. Each value is an array of messages, which matches the shape Zod produces and is easy to render per field.

typescripttypescript
// app/actions.ts
'use server'
 
export async function signUp(prevState: unknown, formData: FormData) {
  const email = String(formData.get('email') ?? '')
  const fieldErrors: Record<string, string[]> = {}
  if (!email.includes('@')) fieldErrors.email = ['Enter a valid email.']
  return { fieldErrors }
}

When validation fails, the action returns the message under the email key. The prevState parameter is required by useActionState, even when this action does not read it.

Read the errors with useActionState

Turn the form into a Client Component and pass the action to useActionState. The hook returns three values: the action's return value as state, the action to hand to the form, and a pending flag. The initial state matches the shape the action returns, which is why it starts as an object with an empty fieldErrors map.

Save the component below as app/signup-form.tsx.

App.tsxApp.tsx
'use client'
import { useActionState } from 'react'
import { signUp } from './actions'
import { EmailField } from './email-field'
export function SignupForm() {
  const [state, action, pending] = useActionState(signUp, { fieldErrors: {} })
  return <form action={action}>
    <EmailField errors={state.fieldErrors.email} />
    <button type="submit" disabled={pending}>Sign up</button>
  </form>
}

The directive marks the file as a Client Component, which the hook requires. Before the first submission the state is the initial value, so no error renders and the form looks untouched.

After a failed submission the state becomes whatever the action returned. The email entry in fieldErrors holds the message array, and the field component below receives it as a prop and decides what to render.

Make the error accessible

The field owns the markup that ties the message to the input. It lives in its own file so every other field can repeat the pattern without copying the wiring.

App.tsxApp.tsx
// app/email-field.tsx
export function EmailField({ errors }: { errors?: string[] }) {
  const message = errors?.[0]
  return <div>
    <label htmlFor="email">Email</label>
    <input id="email" name="email" aria-invalid={!!message}
      aria-describedby={message ? 'email-error' : undefined} />
    {message && <p id="email-error" role="alert">{message}</p>}
  </div>
}

Three attributes do the work here. aria-describedby matches the paragraph id, so a screen reader reads the message when the field takes focus.

aria-invalid marks the field itself as failing, which assistive technology reports alongside the label. The alert role announces the message as soon as it appears, without waiting for focus.

Both ARIA attributes are set only when a message exists, because a description pointing at a missing id is worse than no description at all. Note that this file has no directive of its own. Importing it from a Client Component already places it on the client, so the directive belongs only at the boundary.

Never signal an error through color alone. Red text with no message and no announcement is invisible to screen readers and to visitors who cannot distinguish colors.

Show a pending state too

The third value from the hook is the pending flag used on the button above. While the action runs the button is disabled, so a visitor cannot queue duplicate submissions on a slow connection.

Disabling alone is a weak signal for a visitor who cannot see the button dim, so swap the label to "Signing up..." while pending is true. A changed label is announced, a changed color is not.

Errors from the previous attempt stay on screen during the request. That is what a visitor expects when they are correcting one field and resubmitting.

See server-side form validation with Zod for producing the fieldErrors shape, and useActionState for pending state and server errors for the state lifecycle.

Rune AI

Rune AI

Key Insights

  • Return a fieldErrors object keyed by field name.
  • Read the errors with useActionState.
  • Link each input to its message with aria-describedby.
  • Render the message as text with an alert role.
  • Never signal an error through color alone.
RunePowered by Rune AI

Frequently Asked Questions

Why use aria-describedby instead of just showing text?

aria-describedby links the error element to the input, so screen readers announce the message when the field is focused. Visible text alone does not do that.

Why does the action receive prevState?

useActionState passes the previous state as the first argument. The action receives prevState then FormData, even if it only reads FormData.

Should errors be shown in red only?

No. Color alone is not accessible. Keep the error as text and mark it with role=alert or aria-live so it is announced.

Conclusion

Field-level errors come from returning a fieldErrors object from the Server Action. Read it with useActionState, link each input to its message with aria-describedby, and show the message as text with an alert role so every visitor can see and hear it.