---
title: "Forms"
slug: "forms"
category: "frontend"
tags: ["frontend", "forms", "html", "validation", "accessibility"]
status: "stable"
last_updated: 2026-05-14
summary: "Native HTML forms for everything they can do, server validation always, client validation as a UX layer."
related: ["[[frontend/html]]", "[[frontend/accessibility]]", "[[frontend/react]]", "[[frontend/react-forms]]", "[[frontend/nextjs]]", "[[coding/typescript]]"]
---

## 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.

```html
<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.

```html
<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 [[frontend/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.

```html
<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.

```ts
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 [[frontend/nextjs-server-actions|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 [[frontend/react-forms]] for the React-specific FormData and Zod patterns, [[frontend/nextjs]] and [[frontend/react]] for the framework wiring, and [[coding/typescript]] for typing the `FormData` payload in a shared module.

## Related

- [[frontend/html]]
- [[frontend/accessibility]]
- [[frontend/react]]
- [[frontend/nextjs]]
- [[coding/typescript]]
