Overview
The native <form> is a complete submission, validation, and accessibility primitive; form libraries mostly reimplement parts of it. Build the form natively, then add script on top. The page checklist is html.
Start with a real <form>
Give every form a method and action. The browser then provides submission, Enter-to-submit, autofill, password-manager support, and graceful degradation. A <div> with a button and a fetch handler does none of that and breaks when the script fails to load.
<form method="post" action="/api/signup">
<!-- inputs -->
<button type="submit">Create account</button>
</form>Bind one visible <label> to every input
Use for and id (or wrap the input) so the label is programmatically associated. A placeholder is not a label: it disappears on input and often fails contrast. Add hints with an element referenced by aria-describedby, and group radios, checkboxes, and address blocks in <fieldset> with <legend>. See accessibility.
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required aria-describedby="email-hint" />
<small id="email-hint">We only use this for receipts.</small>Pick the right type and autocomplete
Types change the mobile keyboard and add light validation; autocomplete tokens drive autofill and satisfy WCAG SC 1.3.5.
- Types:
email,tel,url,number,date. Addinputmodeorenterkeyhintto refine the keyboard without changing validation. - Tokens:
email,name,tel,street-address,postal-code,cc-number,cc-exp; use the exact tokens from the HTML spec. - Passwords:
current-passwordfor sign-in andnew-passwordfor sign-up; password managers rely on the difference. - SMS codes:
one-time-code.
Never block paste or password managers in credential fields (WCAG 2.2 SC 3.3.8).
Validate on the server every time
The server is the trust boundary. Client validation gives fast feedback but a stale page, a broken script, or an attacker bypasses it. If the server lacks a check, the check does not exist.
Use client validation as a UX layer
Built-in attributes (required, min, max, pattern, minlength, maxlength, step) cover common cases without JavaScript. Style invalid states with :user-invalid, which activates after interaction, instead of :invalid, which matches at first paint. Both :user-invalid and the Constraint Validation API are Baseline widely available. Use setCustomValidity() for custom messages and show errors in text, wired to the field with aria-describedby; color alone is not enough.
<input type="password" name="password" autocomplete="new-password" minlength="12" required />Read fields with FormData
new FormData(form) captures every field, including files and multi-selects. POST it directly or convert with Object.fromEntries(formData) for JSON endpoints. Hand-built JSON.stringify({ email, password }) drifts from the form whenever a field is added.
form.addEventListener("submit", async (e) => {
e.preventDefault();
await fetch("/api/signup", { method: "POST", body: new FormData(e.currentTarget as HTMLFormElement) });
});Do not disable the submit button on click
A disabled button loses focus and hides the state from assistive technology. Prevent duplicates on the server with an idempotency key or a unique constraint. On the client, keep the button focusable and change its label and aria-busy: <button type="submit" aria-busy="true">Signing up...</button>.
Enhance progressively
A form that works without JavaScript survives CDN outages, ad blockers, and broken bundles. Build <form action="..."> first, then add the fetch handler. Next.js Server Actions follow this model: the action prop takes a server function, which runs on a plain form post without JavaScript. See react-forms for React patterns and typescript for typing the payload.