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.
// 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.
'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/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
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.
Frequently Asked Questions
Why use aria-describedby instead of just showing text?
Why does the action receive prevState?
Should errors be shown in red only?
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.
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.