Handling Server-Side Validation Errors After Submit
Client-side rules catch malformed input, but only the server knows that an email is already taken or a coupon has expired, so this page shows how to map a rejected POST response back onto individual fields using the native Constraint Validation API rather than bolting on a parallel error system. The goal is one submission path where server verdicts surface in the same place, and read the same way, as built-in constraint errors.
When to Use This Response-Mapping Approach
Reach for this pattern when a value is only knowable at the server: uniqueness checks, stock levels, fraud scoring, or authorization outcomes that cannot be expressed as an HTML5 attribute. If the check can run in the browser, keep it there instead.
- Use this when the field passed every client constraint but the server still rejected it (409, 422).
- Use asynchronous server checks when you want to validate before submit, on blur or debounce, rather than after.
- Use Zod schema validation when the same shape must be enforced on both client and server from one definition.
- Prefer Prevent Default Submission Without Losing Validation as the entry point that hands control to the code below.
Mapping a 422 Response with setCustomValidity and reportValidity
The baseline: the form ships novalidate, submission is intercepted, and a single reportValidity() call surfaces both native and server-supplied errors. The server returns a { [fieldName]: message } object under a 422; each message is written onto the matching control with setCustomValidity.
interface FieldErrors {
[fieldName: string]: string;
}
const form = document.querySelector<HTMLFormElement>('#signup')!;
form.addEventListener('submit', async (event: SubmitEvent) => {
event.preventDefault();
// Clear any server errors from a previous attempt so stale messages
// do not block a now-valid field (see Edge Cases below).
clearServerErrors(form);
// Run native + client constraints first. reportValidity() shows the
// browser bubble on the first invalid control and returns false.
if (!form.reportValidity()) return;
const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
submitButton.disabled = true;
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
});
if (response.status === 422) {
const { errors } = (await response.json()) as { errors: FieldErrors };
applyServerErrors(form, errors);
return;
}
if (!response.ok) throw new Error(`Unexpected ${response.status}`);
form.reset();
// ...success handling
} finally {
submitButton.disabled = false;
}
});
function applyServerErrors(form: HTMLFormElement, errors: FieldErrors): void {
for (const [name, message] of Object.entries(errors)) {
const field = form.elements.namedItem(name);
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
// Writing a non-empty string marks the control invalid via customError.
field.setCustomValidity(message);
// Clear the server message as soon as the user edits the field,
// otherwise the stale error sticks even after a valid change.
field.addEventListener('input', () => field.setCustomValidity(''), { once: true });
}
}
// One call surfaces the first server error and focuses its control.
form.reportValidity();
}
function clearServerErrors(form: HTMLFormElement): void {
for (const el of Array.from(form.elements)) {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
el.setCustomValidity('');
}
}
}
Because the message lives in the control’s own validationMessage, the browser handles positioning, focus, and screen-reader announcement without a separate DOM layer. For richer inline presentation, pair this with custom validity messages.
setCustomValidity Server-Mapping: Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
errors payload shape |
Record<string, string> |
{} |
Field-name-to-message map the server returns on 422 |
| status code checked | number |
422 |
HTTP status that signals field-level rejection |
setCustomValidity(msg) |
string |
'' |
Non-empty sets customError; '' clears it |
| clear-on trigger | 'input' | 'change' |
'input' |
Event that wipes the server message once edited |
once listener option |
boolean |
true |
Removes the clear-listener after first edit |
| focus behaviour | reportValidity() |
— | Focuses the first control whose validity is false |
Verification Steps
import { test, expect } from '@playwright/test';
test('server 422 surfaces as a native field error', async ({ page }) => {
await page.route('**/signup', (route) =>
route.fulfill({
status: 422,
contentType: 'application/json',
body: JSON.stringify({ errors: { email: 'That email is already registered.' } }),
}),
);
await page.goto('/signup');
await page.fill('#email', 'taken@example.com');
await page.click('button[type="submit"]');
const message = await page.$eval('#email', (el: HTMLInputElement) => el.validationMessage);
expect(message).toBe('That email is already registered.');
});
Edge Cases & Failure Modes
Stale custom error blocks a corrected field. Once setCustomValidity holds a non-empty string, the control stays invalid until you clear it — a valid new value will not pass validation on its own. The input listener that resets the message to '' is what unblocks resubmission; without it the user is stuck.
Server error targets a field that no longer exists or is unnamed. form.elements.namedItem(name) can return null, a RadioNodeList, or an unrelated element. Guard with instanceof (as above) and route any unmatched keys to a form-level summary so the message is never silently dropped. This is also where accessible error messaging earns its keep.
Focus lands on the wrong control after mapping. reportValidity() focuses the first invalid element in document order, which may not be the field the server flagged. For deterministic behaviour, see focus management after a failed submit and call .focus() explicitly on the first server-flagged control before reporting.
Routing Form-Level and Unmatched Errors
Not every server verdict belongs to a single control. A cross-field rule (“password and confirmation differ”), a rate-limit rejection, or a generic “we could not process this” outcome has no obvious owner among the inputs, and setCustomValidity has nowhere sensible to write it. The Constraint Validation API is deliberately per-element, so these messages need a second channel that sits alongside the native bubbles rather than fighting them.
The robust shape is to split the payload as it arrives: keys that resolve to a real, named control go through setCustomValidity, and everything else — unmatched keys plus an explicit _form bucket — is collected into a live region rendered above the submit button. Dropping an unmatched key silently is the failure worth engineering against, because it produces a form that rejects a submission while showing the user nothing.
interface ServerErrorPayload {
errors?: FieldErrors; // per-field, keyed by control name
formErrors?: string[]; // cross-field / global messages
}
function distributeErrors(form: HTMLFormElement, payload: ServerErrorPayload): void {
const summary: string[] = [...(payload.formErrors ?? [])];
for (const [name, message] of Object.entries(payload.errors ?? {})) {
const field = form.elements.namedItem(name);
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
field.setCustomValidity(message);
field.addEventListener('input', () => field.setCustomValidity(''), { once: true });
} else {
// No control owns this key — surface it instead of discarding it.
summary.push(message);
}
}
renderFormSummary(form, summary);
form.reportValidity();
}
function renderFormSummary(form: HTMLFormElement, messages: string[]): void {
const region = form.querySelector<HTMLElement>('[data-form-errors]');
if (!region) return;
region.textContent = ''; // reset before repainting
region.hidden = messages.length === 0;
for (const text of messages) {
const li = document.createElement('li');
li.textContent = text;
region.appendChild(li);
}
}
Mark the container up as <ul data-form-errors role="alert" aria-live="assertive" hidden> so that repopulating it announces the global failure to assistive technology the moment it appears, without stealing focus from the field-level bubble that reportValidity() raises.
Guarding Against Duplicate Submissions and Late Responses
Disabling the submit button in the finally block re-enables it once the round-trip settles, which is correct for a rejection but reopens a subtler hazard: a user who clicks, waits, and clicks again can put two POSTs in flight, and their responses can land out of order. If the second (stale) response resolves last, it may overwrite freshly mapped errors — or worse, report success for a payload the server already superseded.
Two defences compose cleanly. First, give each submission a client-generated idempotency key so a retried request is deduplicated server-side rather than double-applied. Second, tag each attempt with a monotonic token and ignore any response whose token is no longer current, which neutralises the out-of-order race entirely.
let currentAttempt = 0;
async function submitWithGuard(form: HTMLFormElement): Promise<void> {
const attempt = ++currentAttempt;
const idempotencyKey = crypto.randomUUID();
const response = await fetch(form.action, {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: new FormData(form),
});
// A newer submission started while this one was in flight — drop the result.
if (attempt !== currentAttempt) return;
if (response.status === 422) {
const payload = (await response.json()) as ServerErrorPayload;
distributeErrors(form, payload);
}
}
The attempt !== currentAttempt check is what makes the handler safe under a double-click or an impatient retry: only the most recent request is ever allowed to mutate the form’s validity state, so a slow-arriving stale response can no longer resurrect an error the user has already moved past.
Frequently Asked Questions
Should the server return 400 or 422 for field validation errors?
Use 422 Unprocessable Content when the request was well-formed but a field value is semantically invalid, and reserve 400 for malformed syntax the parser rejects. The distinction lets the client branch cleanly: only 422 carries a per-field errors map worth mapping onto controls.
Can I show multiple server errors at once with reportValidity?
You can set setCustomValidity on every rejected control, but reportValidity() only pops one bubble at a time on the first invalid field. Set all the messages so each control's validationMessage is populated, then render your own inline list if you need every error visible simultaneously.
How do I clear a server error when the user changes the field?
Call field.setCustomValidity('') on the first input event after applying the error. An empty string removes the customError flag so the control's native constraints alone decide validity again. Attaching the listener with { once: true } keeps it from firing on every keystroke.
Where should a global "rate limited" or cross-field error go?
Errors with no single owning control — a rate-limit rejection, a "passwords differ" mismatch, or a generic processing failure — should not be forced onto an arbitrary field with setCustomValidity. Route them to a dedicated role="alert" summary region above the submit button, and reserve the native bubble for genuinely field-scoped messages. This keeps the mapping honest and prevents a global failure from mislabelling a valid input.
How do I stop a double-click from firing two POSTs?
Disable the submit button for the duration of the request and re-enable it in a finally block, then add a monotonic attempt counter so any response from a superseded request is discarded rather than allowed to overwrite current state. Pairing that with a client-generated Idempotency-Key header lets the server deduplicate a retry it does receive, so neither the network nor the UI can apply the same submission twice.
Related Guides
- Prevent Default Submission Without Losing Validation — the intercept step that hands off to this mapping logic.
- Showing an Accessible Loading State — what to render while the POST is in flight.
- How to Use setCustomValidity Correctly — the pitfalls of the API this recipe leans on.
- Reading ValidityState Flags — inspecting
customErrorand its siblings. - checkValidity vs reportValidity — choosing between a silent check and a user-facing one.