Sign Up with Server Validation Errors
How to return business-logic errors from a server action — duplicate email, taken username, and more — and have them appear on the correct fields automatically.
Why it matters
The login form teaches the happy path. This recipe teaches what actually happens in production: the user submits valid data, but the server rejects it because the email is already registered or the username is taken. These are business-logic errors that Zod's schema can't know about.
hookform-action has a built-in contract for this: return { errors: { field: string[] } } from the action and the hook maps it directly to the corresponding React Hook Form fields — no extra wiring needed. This recipe also shows setSubmitError for errors set outside the action, and the onError callback for side effects like toasts.
Full Example
'use server'
import { z } from 'zod'
import { withZod } from 'hookform-action-core/with-zod'
const signupSchema = z.object({
email: z.string().email('Invalid email address'),
username: z.string().min(3, 'Username must be at least 3 characters'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
export const signupAction = withZod(signupSchema, async (data) => {
// data is typed: { email: string; username: string; password: string }
// Simulate DB uniqueness checks
if (data.email === 'taken@example.com') {
return { errors: { email: ['This email is already registered'] } }
}
if (data.username === 'admin') {
return { errors: { username: ['This username is not available'] } }
}
// Multiple field errors at once
if (data.email.endsWith('@disposable.com')) {
return {
errors: {
email: ['Disposable email addresses are not allowed'],
username: ['Please use your real name'],
},
}
}
return { success: true }
})'use client'
import { useActionForm } from 'hookform-action'
import type { InferActionResult } from 'hookform-action'
import { signupAction } from './actions'
// Infer the exact return type of the action for type-safe access
type SignupResult = InferActionResult<typeof signupAction>
export function SignupForm() {
const {
register,
handleSubmit,
setSubmitError,
formState: { errors, isPending, isSubmitSuccessful, actionResult },
} = useActionForm(signupAction, {
defaultValues: { email: '', username: '', password: '' },
onSuccess: (result: SignupResult) => {
// result.success === true here
console.log('Account created!')
},
onError: (result: SignupResult | Error) => {
// Fires when field errors are returned OR the action throws
// Good place for analytics, toast notifications, etc.
if (result instanceof Error) {
console.error('Unexpected error:', result.message)
}
},
})
if (isSubmitSuccessful) {
return (
<div className="text-green-400">
<p>Account created! Check your email to verify.</p>
</div>
)
}
return (
<form onSubmit={handleSubmit()}>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{/* Shows both Zod client errors AND server errors from the action */}
{errors.email && <p className="text-red-400">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="username">Username</label>
<input id="username" {...register('username')} />
{errors.username && <p className="text-red-400">{errors.username.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && <p className="text-red-400">{errors.password.message}</p>}
</div>
{/* Optional: surface a global error if the action returns one */}
{actionResult && 'message' in actionResult && actionResult.message && (
<p className="text-red-400 text-sm">{actionResult.message}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating account…' : 'Sign Up'}
</button>
</form>
)
}// Sometimes you need to set a server error outside the action response,
// for example after a network check or in an event handler:
const { setSubmitError } = useActionForm(signupAction, { ... })
// Set an error on a specific field programmatically:
setSubmitError('email', 'This email was flagged as invalid by our provider')Key Concepts
{ errors: { field: string[] } }The default error contract. Return this shape from any action and defaultErrorMapper automatically sets the corresponding RHF field errors. The key is the field name, the value is an array of message strings (only the first message is shown by RHF by default).
formState.actionResultThe raw return value of the last action call, preserved with full type safety via InferActionResult<typeof action>. Use it to surface global messages or access non-error data from the response.
setSubmitError(field, message)Programmatically sets a field error as if it came from the server. Useful for client-side checks that happen outside the normal submit flow, such as async email-availability lookups on blur.
onError callbackFires when the action returns field errors or throws an exception. Use it for side effects — toast notifications, error logging, analytics — without polluting the action or the component's render logic.
⚠️ Pitfalls
- ⚠
Server errors don't clear when the user retypes
This is intentional RHF behaviour. A server error set via the error mapper persists until the next submit (or until you call
clearErrors(field)manually). To clear on input, usevalidationMode: 'onChange'with a client-side schema. - ⚠
Distinguishing field errors from global errors
The
errorsobject only contains field-level errors from RHF. To display a global error message (e.g. "Service unavailable"), read it fromactionResultand render it separately outside the field markup. - ⚠
withZodalready returns field errors — don't double-validateIf the schema fails,
withZodshort-circuits and returns{ errors }before your handler runs. Do not callschema.safeParseagain inside the handler.