Forms people want to use

Five proposals to keep authors on native forms

Keith Cirkel - TPAC 2026

k9l.io/tpac-forms

Motivation

Production forms submit through script, not the browser

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (!form.checkValidity()) return showErrors(form);
  button.disabled = true;
  try {
    await fetch(form.action, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(Object.fromEntries(new FormData(form))),
    });
  } finally {
    button.disabled = false;
  }
});
  • UI Errors rendered by hand
  • User experience Busy state managed by hand
  • Architecture Encoding and request lifecycle by hand

Motivation

Five form packages pull 1.4M npm downloads a week

decode-formdata @conform-to/dom sveltekit-superforms zod-form-data form-serialize 447k 317k 294k 282k 74k

Weekly npm downloads, 22 to 28 Sep 2026, api.npmjs.org. Excludes axios's built-in formDataToJSON.

CVE-2026-25150: prototype pollution in Qwik City's formToObj()

Motivation

Three problem areas push authors off native forms

  1. UI

    Validation bubbles can't be placed or styled

    Errors appear at the wrong time

    Groups and whole forms have no error

    <error>
  2. User experience

    Nothing stops a second press while submitting

    disabled="while-submitting"
  3. Architecture

    Script submissions have no lifetime

    form.trackSubmission()

    Servers re-implement HTML's rules

    FormValidation

    Forms can't send JSON

    enctype="application/json"

UI

Validation bubbles sit outside the page

  • Browser-drawn, briefly Ignores the page's CSS; flags only the first invalid field, and fixing it updates nothing
  • Cancel invalid to hide it, then rebuild Placement, aria-errormessage, updating as validity changes

Open UI (open-ui.org) resolved this is worth solving generally, during customisable <select> work.

Jen Simmons @jensimmons.bsky.social

I'm pitching <control> and <error>. As in:

<control>
  <label>...</label>
  <input>...</input>
  <error>...</error>
</control>
bsky.app · 16 Oct 2026 · replies converging on the same pattern

UI

<error> puts the browser's own message in the page

<html lang="en-GB">
<label for="email">Email (required)</label>
<error for="email"></error>
<input id="email" type="email" required>
error { display: block; visibility: hidden; }
:user-invalid::error { visibility: visible; }

Built-in text follows the <error>'s lang, not the browser locale. Custom text comes from setCustomValidity().

UI

Groups and forms get errors too

<fieldset id="passwords">
  <error for="passwords">
    <a href="#password">Back to the first password</a>
  </error>
  <input id="password" type="password" required>
  <input id="confirmation" type="password" required>
</fieldset>
passwords.setCustomValidity("Passwords don't match.");
  • Fieldset errors block submission Inputs keep their own validity. Today these don't block, so this needs compat data
  • Forms gain setCustomValidity() For errors no field owns, such as a server rejection
  • Children add rich UI Browser text goes in ::validation-message before them, as plain text

Open: a fieldset matches :user-invalid when a field inside does, so CSS can't yet show only its own error.

User experience

Stopping double submits by hand breaks the payload

A press with no visible result gets pressed again, causing duplicate orders and double payments.

<button name="action" value="pay">Pay</button>
<script>
  pay.addEventListener("click", () => {
    pay.disabled = true;
  });
</script>
POST /checkout

card=4242424242424242&action=pay

A disabled submitter isn't submitted, so the server can't tell which button was pressed.

User experience

disabled="while-submitting" locks controls until the work ends

<form method="post" action="/checkout">
  <fieldset disabled="while-submitting">
    <input name="card" required>
    <button name="action" value="pay">Pay</button>
  </fieldset>
  <button type="reset">Clear</button>
</form>
  • FormData built first, then disabled The request keeps card and action=pay
  • Any control, fieldset or form Opt in where needed; Clear stays usable. Controls with form= follow their form.

Old browsers read plain disabled, so the fields never work.

Architecture: submission state

After preventDefault(), the browser can't see the submission

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  button.disabled = true;
  try {
    const res = await fetch(form.action, opts);
    status.textContent = await res.text();
  } finally {
    button.disabled = false;
  }
});
  • The browser saw a cancelled submission. The real one is invisible to it.
  • This finally re-enables while another caller's upload still runs.
  • Every page rebuilds busy state. No native feature can read it.

Architecture: submission state

trackSubmission() ties script work to the form

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const data = new FormData(form, event.submitter);
  form.trackSubmission(save(data));
  form.trackSubmission(logAnalytics(data));
});
Native submission
ends at preventDefault()
save(data)
logAnalytics(data)
rejects
Form is submitting

Submitting until every contribution settles, like Promise.allSettled().

Architecture: submission state

A hold pauses native submission until async checks pass

form.addEventListener("submit", () => {
  const check = checkUsername(username.value)
    .then((msg) => username.setCustomValidity(msg));
  form.trackSubmission(check, { hold: true });
});
checkUsername()
fulfils
Native submission
waits
revalidates, sends
Form is submitting

A rejection or failed revalidation ends the attempt. No second submit event.

Architecture: validation

Servers restate HTML's rules, and disagree with the browser

Field Value Browser Typical server check
pattern="[0-9]+" 12a invalid valid: JSON Schema, Zod regex
type=email admin@localhost valid invalid: Zod z.email()
min=0.25 step=.5 1 invalid valid: multipleOf: 0.5
<textarea maxlength=9> abcd\nefgh valid invalid: z.string().max(9)

Conform and superforms map schemas to attributes, one way and lossily.

Architecture: validation

FormValidation runs HTML's own constraints on any FormData

JSON.stringify(new FormValidation(form));
{"email": {"type": "email", "required": true},
 "username": {"type": "text", "required": true,
   "pattern": "[a-z0-9_]+", "maxLength": 20},
 "age": {"type": "number", "min": "13"}}
// any WinterTC server runtime
const rules = new FormValidation(signup);
const { valid, fields } =
  rules.validate(await request.formData());
email=me typeMismatch
username=Keith patternMismatch
age=12 rangeUnderflow

Architecture: JSON

JSON endpoints force forms into script

<form method="post"
      action="/api/posts">
  <input name="title">
  <button>Publish</button>
</form>
form.onsubmit = async (e) => {
  e.preventDefault();
  const data = new FormData(form);
  await fetch(form.action, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(Object.fromEntries(data)),
  });
};

Architecture: JSON

Hand-rolled conversions disagree on the same form

<input type="checkbox" name="tag"
       value="magic" checked>
<input type="checkbox" name="tag"
       value="horns" checked>
Object.fromEntries(new FormData(form))
// { tag: "horns" }
Approach Both ticked Only magic ticked
Object.fromEntries(), Next.js tutorial "horns" "magic"
FormData.get() "magic" "magic"
htmx json-enc, Hono { all: true }, Bun ["magic","horns"] "magic"
superforms, Astro array schema ["magic","horns"] ["magic"]

Architecture: JSON

Ship the conversion first, then let enctype reuse it

tag[]=magic, pet[0][name]=Hypatia Form entry list, with control type hints

converted by

stage 1 FormData.toJSON(), .fromJSON() XHR: one JSON shape for script and servers

submitted by

stage 2 enctype="application/json" HTML sends it; Fetch preflights it
  • Useful alone Fixes the lossy idiom. Sends no requests, so no CSRF change.
  • Composes both ways
    JSON.stringify({ draft: new FormData(form) })
    FormData.fromJSON(await request.json())
  • Submission reuses it One representation for script and forms.

Architecture: JSON

Bracket names nest, and [] keeps an array an array

<input name="title" value="Unicorns">
<input type="checkbox" name="tag[]"
       value="magic" checked>
<input type="checkbox" name="tag[]"
       value="horns">
<input name="pet[0][name]" value="Hypatia">
<input name="pet[1][name]" value="Billie">
{"title": "Unicorns",
 "tag": ["magic"],
 "pet": [{"name": "Hypatia"},
         {"name": "Billie"}]}

Architecture: JSON

The control, not the user, decides a value's type

<input name="zip" value="01234">
<input type="number" name="legs" value="4">
<input type="number" name="age" value="">
<input type="checkbox" name="draft" checked>
<input type="checkbox" name="pinned">
<input type="radio" name="size" value="s">
{"zip": "01234",
 "legs": 4,
 "age": null,
 "draft": true,
 "pinned": false,
 "size": null}

Architecture: JSON

Spellings canonicalise, real ambiguity throws

Names Conversion
kids[1], kids[0] canonicalised: kids[], kids[], in label order
pet["name"], pet["legs"] canonicalised: pet[name], pet[legs]
tag, tag throws: write tag[]
mix, mix[a] throws: a value can't have keys
t[0], t[a] throws: array or object?
pet[01] throws: write pet["01"]
Rails' hidden input + checkbox throws, ticked or not

Each control counts once, so without script, errors depend only on the names.

Architecture: JSON

One attribute sends the same JSON as a real navigation

<form method="post" action="/api/posts"
      enctype="application/json">
  <input name="title" value="Unicorns">
  <input type="checkbox" name="tag[]"
         value="magic" checked>
  <button>Publish</button>
</form>
POST /api/posts
Content-Type: application/json

{"title":"Unicorns","tag":["magic"]}
<button formenctype="application/json">Publish as JSON</button>

Without support, browsers send urlencoded, so accept both or feature-detect.

Architecture: JSON

Today, application/json proves a request wasn't a form

app.example to api.example <form> fetch() no-cors fetch() CORS
urlencoded, multipart, text/plain sent sent sent
application/json never never after preflight
application/json, proposed after preflight never after preflight

So many servers skip CSRF tokens when they see it.

Forms get fetch's existing rule, where the server opts in with its exact origin and credentials, or the POST is never sent.

Architecture: JSON

Form navigations need preflights anyway, for new methods

Method Safelisted
GET, HEAD, POST yes
PUT, PATCH, DELETE no: preflight
QUERY unsettled
  • whatwg/html#11347 method="put|patch|delete" on forms, through CORS
  • whatwg/fetch#1785 Navigations preflight when a method or header isn't safelisted: covers application/json
  • RFC 10008 + whatwg/html#12594 QUERY: a safe method with a body, for JSON search forms

One Fetch change serves all of these proposals.

Architecture: JSON

Uploads send multipart/json: the JSON first, then the files

application/json carries file names only. Uploads need the bytes.

  • Never safelisted Loose type checks would parse attacker JSON (Fastify CVE-2022-41919)
  • Parsers fail loudly busboy throws on the new type instead of dropping fields
  • Files named by JSON Pointer /pet/0/photo is where that file's name sits in the JSON
Content-Type: multipart/json; boundary=b

--b
Content-Disposition: form-data; name=""
Content-Type: application/json

{"title":"Unicorns","photo":"unicorn.png"}
--b
Content-Disposition: form-data; name="/photo";
                     filename="unicorn.png"
Content-Type: image/png

<file bytes>
--b--

Together

The same form, with no script on the page

<form method="post" action="/api/signup"
      enctype="application/json">
  <label for="email">Email (required)</label>
  <error for="email"></error>
  <input id="email" name="email" type="email" required>
  <button disabled="while-submitting">Sign up</button>
</form>
const data = FormData.fromJSON(await request.json());
const { valid, fields } = rules.validate(data);
  • UI The browser's message, in the page
  • User experience No second press, payload intact
  • Architecture JSON as a real navigation
  • Architecture Server checks the browser's rules

Next

Each proposal ships alone, and each needs your edge cases

Proposal Lands in Open question for you
<error> HTML; ::error, ::validation-message in CSS How does CSS spot a container's own error?
disabled="while-submitting" HTML Is the old-browser fallback acceptable?
form.trackSubmission() HTML When may script register work?
FormValidation HTML, then WinterTC One rule set for form and JSON names?
JSON encoding XHR + Fetch, then HTML Should disabled controls send null?