R
Rishtaara
Next.js: Zero to Full-Stack
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>
  );
}