Recipe

Progressive enhancement forms

Ship a zero-JS HTML form that works in every browser, then layer on client-side validation and optimistic UI when JavaScript loads.

Step 1 — Server-rendered form

<form action="/api/subscribe" method="POST">
  <input
    type="email"
    name="email"
    required
    placeholder="you@example.com"
  />
  <button type="submit">Subscribe</button>
</form>

The required attribute gives native browser validation. The form POSTs even with JS disabled.

Step 2 — Sprinkle client validation

<script>
  const form = document.querySelector("form");
  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const email = form.email.value;
    if (!email.includes("@")) {
      showError("Invalid email");
      return;
    }
    const res = await fetch(form.action, {
      method: "POST",
      body: new FormData(form),
    });
    if (res.ok) showSuccess();
  });
</script>

The script only runs when JS is available. The server endpoint handles both the enhanced fetch and the plain form POST.

Step 3 — Optimistic UI

// Show success immediately, roll back on failure
showSuccess();
try {
  await fetch("/api/subscribe", {
    method: "POST",
    body: new FormData(form),
  });
} catch {
  showError("Failed — please try again");
}

Optimistic updates make the UI feel instant. Always provide a rollback path when the server rejects the request.

Key principle

Start with working HTML. JavaScript is a progressive enhancement — never a requirement. Your form must function fully with<noscript>users, screen readers, and slow networks.