Lesson 28 of 42Article16 min
Improving Accessibility & Server Form Validation
Labels, focus order, and clear errors help everyone — keyboard users, screen reader users, and tired users on mobile. In forms, accessibility and validation go together.
Accessibility is part of the product
Labels, focus order, and clear errors help everyone — keyboard users, screen reader users, and tired users on mobile. In forms, accessibility and validation go together.
Real-life example: A good form is a well-marked train station — platforms labeled, announcements clear, and staff who stop you before you board the wrong train (server validation).
- Every input needs a visible <label htmlFor=...>
- Use aria-invalid and aria-describedby when showing errors
- Keep focus management in mind after failed submit (error summary first)
- Do not rely on color alone — pair red text with words or icons
Server-side validation with Server Actions
Validate on the server even if the browser has required and type=email. Return field errors instead of throwing opaque failures when input is wrong.
Browser checks are a courtesy; server validation is the gate. Pair them with labels, role="alert" for errors, and role="status" for success so assistive tech announces results.
Validation + accessible form
// app/contact/actions.ts
"use server";
export type ContactState = {
ok: boolean;
errors: { email?: string; message?: string };
};
export async function sendContact(
_prev: ContactState,
formData: FormData
): Promise<ContactState> {
const email = String(formData.get("email") ?? "").trim();
const message = String(formData.get("message") ?? "").trim();
const errors: ContactState["errors"] = {};
if (!email.includes("@")) {
errors.email = "Enter a valid email address.";
}
if (message.length < 10) {
errors.message = "Message must be at least 10 characters.";
}
if (Object.keys(errors).length) {
return { ok: false, errors };
}
// await saveToDb({ email, message });
return { ok: true, errors: {} };
}
// app/contact/page.tsx (client wrapper for useActionState)
"use client";
import { useActionState } from "react";
import { sendContact, type ContactState } from "./actions";
const initial: ContactState = { ok: false, errors: {} };
export function ContactForm() {
const [state, action, pending] = useActionState(sendContact, initial);
return (
<form action={action} noValidate>
{state.ok && <p role="status">Thanks — we got your message.</p>}
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="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}
</p>
)}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
rows={4}
aria-invalid={Boolean(state.errors.message)}
aria-describedby={state.errors.message ? "message-error" : undefined}
/>
{state.errors.message && (
<p id="message-error" role="alert">
{state.errors.message}
</p>
)}
</div>
<button type="submit" disabled={pending}>
{pending ? "Sending…" : "Send message"}
</button>
</form>
);
}