Overview
Forms are the most common interactive surface on the web and the easiest to get wrong. The native <form> element is a complete submission, validation, and accessibility primitive; most form libraries reimplement parts of it badly. This page covers the rules that keep forms accessible and cheap to build.
Native HTML forms for everything they can do
Start every form as a real <form> with a method and action. The browser gives you submission, Enter-to-submit, autofill, the back-forward cache, and graceful degradation for free. Wire JavaScript on top; do not replace the form.
<form method="post" action="/api/signup">
<!-- inputs -->
<button type="submit">Create account</button>
</form>A <div> with a button and a fetch handler is not a form. It does not submit on Enter inside a single-line input, it is invisible to password managers, and it breaks when the script fails to load.
One <label> per input, bound explicitly
Every control needs a visible, programmatically associated label. Use for and id, not label-wrapping, for predictable styling and click targets.
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />Placeholder text is not a label. It disappears on focus, fails contrast, and confuses autofill. For an inline hint, pair the input with a <small id="email-hint"> and reference it via aria-describedby.
Group related controls (radios, checkboxes, address blocks) in a <fieldset> with a <legend>. The legend names the group for screen readers; see accessibility.
Pick the right type and autocomplete
Input types and autocomplete tokens are the most under-used accessibility win on the web. They change the mobile keyboard, autofill suggestions, and password manager behavior.
type="email","tel","url","number","date". Each changes the keyboard and adds light client validation.autocomplete="email","name","tel","street-address","postal-code","cc-number","cc-exp".- Passwords:
autocomplete="current-password"for sign-in,"new-password"for sign-up. Password managers respect the difference. - SMS codes:
autocomplete="one-time-code". iOS and Android surface the code above the keyboard.
Pick the most specific token from the HTML spec; do not invent your own.
Server validation is the only validation that counts
Validate every field on the server, every time. The server is the trust boundary; the client is a UX layer.
Client validation exists to give the user fast feedback, not to gate submission. A determined attacker, a broken script, or a stale page bypasses it. If the server is missing a check, the check does not exist.
Client validation as a UX layer
Use the Constraint Validation API for inline feedback. Built-in attributes (required, min, max, pattern, minlength, maxlength, step) cover the common cases without JavaScript.
<input
type="password"
name="password"
autocomplete="new-password"
minlength="12"
required
aria-describedby="pw-hint"
/>
<small id="pw-hint">12 characters or more.</small>Style invalid states with :user-invalid, not :invalid. :user-invalid activates after interaction, so the field is not red on first paint. For richer errors, set setCustomValidity() and read validity.valid before submission.
Prefer FormData over hand-rolled JSON
Use new FormData(formEvent.target) to read every field at once. It captures files, multi-select values, and grouped inputs correctly. POST it directly, or convert with Object.fromEntries(formData) when the endpoint expects JSON.
form.addEventListener("submit", async (e) => {
e.preventDefault()
const data = new FormData(e.currentTarget as HTMLFormElement)
await fetch("/api/signup", { method: "POST", body: data })
})Hand-rolling JSON.stringify({ email: ..., password: ... }) drifts from the form every time a field is added.
Never disable the submit button on click
Disabling the submit button feels safe and breaks accessibility. A disabled button drops focus, suppresses Enter-to-submit during retries, and confuses assistive tech.
Handle duplicate submissions on the server with an idempotency key or a unique constraint. On the client, swap the label to a loading state and keep the button focusable: <button type="submit" aria-busy="true">Signing up...</button>.
Progressive enhancement is free resilience
A form that works without JavaScript survives a CDN outage, an ad blocker, and a broken bundle. Build <form action="/api/..."> first; the page works on submit. Add the fetch handler second; it intercepts and improves the same flow.
Next.js Server Actions follow this model. The action prop accepts a server function; without JS the browser submits, with JS the framework intercepts and re-renders. Same pattern in Remix and SvelteKit. See react-forms for the React-specific FormData and Zod patterns, nextjs and react for the framework wiring, and typescript for typing the FormData payload in a shared module.