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
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
-
UI
Validation bubbles can't be placed or styled
Errors appear at the wrong time
Groups and whole forms have no error
<error>
-
User experience
Nothing stops a second press while submitting
disabled="while-submitting"
-
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
Email
Please match the requested format.
-
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.
JS
Jen Simmons @jensimmons.bsky.social
I'm pitching <control> and <error>. As in:
<control>
<label>...</label>
<input>...</input>
<error>...</error>
</control>
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; }
Email (required)
Please match the requested format.
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.
- Also by hand:
- re-enable on failure
- spot native completion
- every control, even outside the form
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));
});
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 });
});
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: 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)),
});
};
- Lost:
works without script
navigation
history
response page
back/forward
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
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"}]}
-
Syntax PHP, Rails and qs already parse
Older browsers fall back to urlencoded, which decodes to this shape.
-
Labels are positions
kids[0] with kids[2] throws: no padding.
Invalid or clashing names throw too.
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? |