HTML forms and the FormData contract
Author native HTML forms whose name attributes become the FormData keys a server reads and validates.
You sit down to build a sign-up form for Acme, your invoicing app.
A tutorial-trained instinct reaches for React state: a useState per field, a value and onChange on each input, and a submit handler that reads it all back into an object to send.
That is thirty lines of plumbing per form, written before you validate a single field.
There is another starting point: a plain <form> of labeled inputs, each carrying a name, a <button type="submit">, and a server that reads the submission and validates it.
The browser already collects the fields and sends them, so those thirty lines reimplement a feature the platform ships for free.
This lesson builds that second instinct and stops at the markup.
A form is a contract the server reads: the name attributes on the form and the keys your server expects are the same list, so you write them together.
A form is a contract the server reads
Section titled “A form is a contract the server reads”- The user fills in some labeled fields and clicks submit.
- The browser walks every control , reads each one’s
nameand value, and packs them into aFormDataobject. - That
FormDatacrosses the wire to your server. - A Zod schema validates it and hands you back a clean, typed object.
Steps 2 and 4 speak the same vocabulary: the name strings.
The browser keys the data under name="email", and the schema looks for a key called email.
When the two lists agree, the form round-trips with almost no glue code; when they disagree, from a typo or a one-sided rename, a field the user filled in silently never arrives.
That shared list of strings is the contract, so you design the form and the schema together rather than building the form and then figuring out how to read it.
Drag through the four steps below and follow the name strings from markup to validated object.
A native HTML form is progressive enhancement by default: the round-trip above runs whether or not your JavaScript has loaded, because the browser submits the form on its own. Everything React adds later, such as pending states, inline errors, and optimistic updates, builds on this working baseline rather than replacing it.
The wire itself, whether a Server Action or a plain POST endpoint, is a later chapter’s job.
What the <form> element controls on submit
Section titled “What the <form> element controls on submit”The <form> element marks which controls belong together and owns what happens on submit, through three attributes.
action says where the contract is sent.
In plain HTML it is a URL string.
In a React app it is usually a function, the Server Action, and React wires up the network call for you, as in <form action={createAccount}>.
Omit action and the form posts back to the current URL.
method is get or post.
With get, the default, the field values are appended to the URL as a query string, right for a search or filter form whose submission is a navigation you might bookmark or share.
With post, the values travel in the request body, out of the URL.
The rule is one sentence: anything that changes server state is post.
Signing up, creating an invoice, and deleting a customer are all post.
encType controls how the body is encoded, and you change it for exactly one reason.
The moment a form contains an <input type="file">, switch to multipart/form-data so the file’s bytes can ride along.
The submit path needs no JavaScript.
A <button type="submit"> inside the form, or pressing Enter while focused in a text field, triggers the browser’s native submit: it serializes the named controls and sends them according to method and action.
This is why every button in a form needs its type spelled out.
A button with no type defaults to submit, so a “Cancel” or “Show password” button you meant to be inert will quietly fire the form instead.
The Acme sign-up form starts as a bare shell, without the labels and names that the next two sections add.
<form action={createAccount} method="post"> <input type="email" /> <input type="password" /> <button type="submit">Create account</button></form>The name attribute is the contract
Section titled “The name attribute is the contract”name is the key under which a control’s value shows up in FormData.
An input with a name contributes one entry; an input without one contributes nothing at all and is invisible to the submission.
This is the most common forms bug, and it is silent.
The user fills in a field, sees it on screen, clicks submit, and the value never reaches your server because someone forgot the name. No error, no empty string, just gone.
On a stack of Server Actions and Zod, schema keys derive from your database columns, which TypeScript reads as camelCase, so your name attributes should be camelCase too.
name="organizationName" lines up exactly with the organizationName key in your Zod schema and the field on your typed object: one vocabulary across the form, the schema, and the table.
Kebab-case like name="organization-name" is valid HTML, but it forces you to map the name back to organizationName at the boundary.
The exercise below breaks the contract in three places. Repair it so every field reaches the server under the right key.
The server's Zod schema (read-only, shown in the comment) expects three keys: email, password, and organizationName. The form below breaks the contract in three places — two inputs are missing their name entirely, and one has a name that doesn't match. Add or fix the name attributes so every field reaches the server under the exact key the schema expects.
Reveal the fixed contract
<form action={createAccount}> <label htmlFor="email">Email</label> <input id="email" type="email" name="email" />
<label htmlFor="password">Password</label> <input id="password" type="password" name="password" />
<label htmlFor="organizationName">Organization name</label> <input id="organizationName" type="text" name="organizationName" />
<button type="submit">Create account</button></form>The email and password inputs had no name, so the browser dropped both silently.
The organization field did submit, but under org-name, a key the schema never looks for, so it was ignored too.
With every name matching its schema key, one vocabulary now runs across the form and the schema.
Note that id and name hold the same string here by convention, not requirement: the id links the <label>, the name keys the server.
Labels are the second contract
Section titled “Labels are the second contract”The name attribute is a contract with the server.
The <label> is a second contract, with the user and their tools, and it is easy to mistake for decoration.
It serves three audiences at once: a screen reader announces it when focus lands on the field (without one the input reads as “edit text, blank”); it is a click target, so tapping the word “Email” focuses the input; and its text tells a password manager what the field holds.
There are two ways to tie a label to its input. The explicit form connects them by id:
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />The implicit form wraps the input inside the label, and the nesting is the association:
<label> Email <input name="email" type="email" /></label>Prefer the explicit htmlFor/id pairing: the link holds however the markup moves, so you can wrap the input in three more <div>s without breaking it.
One distinction trips up nearly everyone: htmlFor takes the input’s id, never its name.
They are different keys for different machines:
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />The label points at a field by its id, linking the announced text to the box so clicking “Email” focuses the input.
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />The field’s DOM address and the other end of that link, so it must be unique on the page. Same color as htmlFor because the two are one connection.
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />A different contract: the key this value lands under in FormData, the wire to the server. names must be unique within the form, except radio groups, which deliberately share one.
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />The UX: it picks the on-screen keyboard and a loose browser check. id and name look interchangeable because they so often hold the same string, until a radio group pulls them apart.
In short: id is the field’s address for the label; name is its key for the server.
A <label> is also not a placeholder, the grey hint inside an empty input.
A placeholder vanishes the moment the user types, exactly when they want to re-check the field, and screen readers treat it inconsistently, so it can supplement a label with an example (“e.g. ada@acme.com”) but never replace one.
Input types and the type attribute
Section titled “Input types and the type attribute”The type attribute decides how an <input> behaves: the on-screen keyboard on mobile, the autofill suggestions, the native picker (a date wheel, a color swatch), and a layer of cheap validation.
What it does not change is the value that reaches your server: every input value arrives as a string, whatever the type.
These are the SaaS-relevant types; reach for the row that matches what the field means.
type | Use it for | Worth knowing |
|---|---|---|
text | Names, free single-line text | The default. maxLength/minLength bound the length. |
email | Email addresses | Email keyboard on mobile; a loose @ shape check. |
password | Secrets | Masks the input. Pairs with autoComplete (next section). |
number | Quantities, amounts | Still arrives as a string ("42"). Use min/max/step to bound it; maxLength is ignored here. |
tel | Phone numbers | Telephone keypad on mobile. No format enforcement, since formats vary by country. |
url | Links, websites | URL keyboard; a loose URL shape check. |
date | Calendar dates | Native date picker. Value is an ISO string, yyyy-mm-dd. |
time | Times of day | Native time picker. |
datetime-local | A local date and time | Combined picker, no timezone. |
checkbox | A boolean or a multi-pick option | Submits its value only when checked (see the next section; this one bites). |
radio | One choice from a set | Several radios share one name; only the checked one submits. |
file | File uploads | Uncontrolled, so React can’t set its value; read it from a ref. Needs multipart/form-data. |
hidden | Carrying data the user doesn’t edit | The classic use: an invoice id on an edit form, so the server knows which record to update. |
The date value is a plain ISO string like "2026-06-01", all you need for now.
And while type="submit" exists, prefer <button type="submit">: a button can hold rich content like an icon or a spinner, where a submit input holds only a flat label.
Every input value is a string in FormData, even type="number".
The user types 42, your server receives "42", and you coerce it server-side, where your Zod schema turns it into a real number with z.coerce.number() (a later chapter).
type is UX, not a server guarantee.
type="email" nudges the keyboard and runs a friendly browser check, but it does not force a valid email to arrive: a scripted or malicious client can POST anything to your endpoint without ever touching your <input>.
The last section returns to this; for now, hold that type is a courtesy to honest users, not a barrier against dishonest ones.
Match each Acme sign-up field to the input type it should use.
Match each Acme sign-up field to the input `type` it should use. Click an item on the left, then its match on the right. Press Check when done.
emailteldatepasswordurlOther controls and what each adds to FormData
Section titled “Other controls and what each adds to FormData”For each control beyond <input>, ask the same question: what does it contribute to FormData?
<textarea> is multi-line text, such as an invoice note.
It takes a name like any control and a rows attribute for its visible height.
The one React wrinkle: where plain HTML puts the initial value between the tags, React reads it from a defaultValue prop.
<textarea name="notes" rows={4} defaultValue="" /><select> and <option> build a dropdown.
The name goes on the <select>, and FormData receives the value of the chosen <option>.
Give every option an explicit value: omit it and the option submits its visible text instead, coupling your data to your copy.
<select name="plan" defaultValue="free"> <option value="free">Free</option> <option value="pro">Pro</option> <option value="scale">Scale</option></select>Add the multiple attribute and one name can yield several entries at once, which the server reads with FormData.getAll(name) rather than .get(name).
The single-select dropdown above is the common case.
The checkbox is where people get it wrong.
An <input type="checkbox"> contributes its value to FormData only when checked; unchecked, it contributes nothing, not "false", not an empty value, the key is simply absent.
And without an explicit value, a checked box submits the string "on", which is rarely what you want, so always set one:
<input type="checkbox" name="acceptedTerms" value="true" />Radio groups run on one rule: several <input type="radio"> controls that share a single name form one group, and only the checked one’s value is submitted, once, under that shared name.
This is the promised case where id and name come apart: each radio needs its own unique id for its own label, but they all share the one name that defines the group.
<input type="radio" id="billMonthly" name="billing" value="monthly" /><label htmlFor="billMonthly">Monthly</label><input type="radio" id="billYearly" name="billing" value="yearly" /><label htmlFor="billYearly">Yearly</label><fieldset> and <legend> group related controls into one logical unit, such as the radio group above or a billing-address block.
The <legend>, which must come first, names the group, and a screen reader announces it before each control inside.
When several controls answer one question, reach for this rather than a <div>.
The tabs below show each tricky control as a before-and-after: its state on the left, the resulting FormData on the right.
Now predict it yourself.
The user submits this form without touching any control. List the entries the browser packs into FormData — one `key=value` per line, in document order. Omit any field that contributes no entry. Predict what this program prints, then press Check.
<form> <input type="checkbox" name="newsletter" value="yes" defaultChecked /> <input type="checkbox" name="acceptedTerms" value="true" /> <input type="radio" name="billing" value="monthly" /> <input type="radio" name="billing" value="yearly" defaultChecked /></form>Two entries, no more. The checked newsletter box submits its value, giving newsletter=yes. The radio group shares one name, and only the checked member submits, once: billing=yearly, while the monthly radio contributes nothing. The trap is acceptedTerms: unchecked, it submits nothing at all, not "false", not an empty value, the key is simply absent. That checked-or-absent asymmetry is why a checkbox can’t be read as a naive boolean on the server, where a missing key has to mean “false.”
autoComplete: the autofill contract
Section titled “autoComplete: the autofill contract”Where name is the contract with your server, autoComplete is the contract with the autofill engine: the attribute browsers and password managers read to fill the right saved value and to know what to store when the user types a new one.
Get it right and you lift sign-up conversion; the field stays invisible until it misfires.
You set it to a semantic token naming what the field holds.
In a web app that means email, username, current-password, new-password, given-name, family-name, name, organization, street-address, postal-code, country, and tel; a billing form adds the credit-card family cc-number, cc-name, and cc-exp.
The password pair is the part to understand.
On a sign-in form, autoComplete="current-password" tells the password manager to fill the saved password.
On a sign-up or change-password form, autoComplete="new-password" tells it to offer a freshly generated strong one and save it.
Get it wrong and the manager fills a sign-up form with the user’s existing password.
autoComplete="off" on a password field is a regression, not a security measure: it fights the password manager your users rely on and pushes them toward weaker, reused passwords.
Use the semantic token instead.
The one legitimate use for off is a value that is never reused, like a two-factor code, where there is nothing to save and nothing to fill.
These two tabs show the same email and password fields without and with the autofill tokens.
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />
<label htmlFor="password">Password</label><input id="password" name="password" type="password" />The browser is guessing. With no autoComplete, it has only the field names to go on, so a password manager may fill the wrong field or fail to offer to save the new credentials.
<label htmlFor="email">Email</label><input id="email" name="email" type="email" autoComplete="email" />
<label htmlFor="password">Password</label><input id="password" name="password" type="password" autoComplete="new-password" />Two attributes, a markedly better sign-up. autoComplete="email" offers the saved address; autoComplete="new-password" marks this as a sign-up, so the manager generates and saves a strong password instead of filling an old one.
HTML validation is UX, not security
Section titled “HTML validation is UX, not security”HTML gives you validation attributes that run in the browser before the form is sent, catching honest mistakes early and saving the user a pointless round-trip. They make the form pleasant, but the server-side Zod schema is what makes it safe.
The constraints are cheap to add:
requiredblocks submit and shows a browser message when the field is empty.type-based checks likeemail,url, andnumberenforce a loose shape for free.min/maxbound a number or a date to a range.minLength/maxLengthbound a string’s length. (maxLengthis ignored ontype="number"; usemin/maxthere.)patternis a regex the value must match. The same rule reads far more legibly as a Zod schema on the server, so you rarely reach for it.
Client-side constraints can always be bypassed: DevTools can delete required, and a script or curl can POST your endpoint without ever loading the page.
So every constraint on the markup is a courtesy to honest users, while the matching rule in your Zod schema is the one that actually enforces it.
Validate on the client for the user and on the server for the system; trust only the server.
Two paths reach your server, and both still hit Zod.
Sort each item by which side of the trust boundary it lives on.
Each item is a way to check a sign-up field. Sort it by which side of the trust boundary it lives on. Drag each item into the bucket it belongs to, then press Check.
required attributetype="email" on the inputpattern regex on the inputminLength on the inputz.email()safeParse of the FormDataThe complete sign-up form
Section titled “The complete sign-up form”The whole Acme sign-up form assembled, with each concern labeled below.
<form action={createAccount} method="post"> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="password">Password</label> <input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required />
<label htmlFor="organizationName">Organization name</label> <input id="organizationName" name="organizationName" type="text" required />
<label htmlFor="plan">Plan</label> <select id="plan" name="plan" defaultValue="free"> <option value="free">Free</option> <option value="pro">Pro</option> <option value="scale">Scale</option> </select>
<label> <input type="checkbox" name="rememberMe" value="true" /> Remember me </label>
<label> <input type="checkbox" name="acceptedTerms" value="true" required /> I accept the terms </label>
<button type="submit">Create account</button></form>On submit, everything inside the <form> is sent to createAccount as a post.
<form action={createAccount} method="post"> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="password">Password</label> <input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required />
<label htmlFor="organizationName">Organization name</label> <input id="organizationName" name="organizationName" type="text" required />
<label htmlFor="plan">Plan</label> <select id="plan" name="plan" defaultValue="free"> <option value="free">Free</option> <option value="pro">Pro</option> <option value="scale">Scale</option> </select>
<label> <input type="checkbox" name="rememberMe" value="true" /> Remember me </label>
<label> <input type="checkbox" name="acceptedTerms" value="true" required /> I accept the terms </label>
<button type="submit">Create account</button></form>The keys. These name strings are the exact list your server’s Zod schema expects.
<form action={createAccount} method="post"> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="password">Password</label> <input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required />
<label htmlFor="organizationName">Organization name</label> <input id="organizationName" name="organizationName" type="text" required />
<label htmlFor="plan">Plan</label> <select id="plan" name="plan" defaultValue="free"> <option value="free">Free</option> <option value="pro">Pro</option> <option value="scale">Scale</option> </select>
<label> <input type="checkbox" name="rememberMe" value="true" /> Remember me </label>
<label> <input type="checkbox" name="acceptedTerms" value="true" required /> I accept the terms </label>
<button type="submit">Create account</button></form>Every input is labeled. The explicit htmlFor/id link survives refactors; the checkboxes use the implicit form, nesting the <input> inside the <label>.
<form action={createAccount} method="post"> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="password">Password</label> <input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required />
<label htmlFor="organizationName">Organization name</label> <input id="organizationName" name="organizationName" type="text" required />
<label htmlFor="plan">Plan</label> <select id="plan" name="plan" defaultValue="free"> <option value="free">Free</option> <option value="pro">Pro</option> <option value="scale">Scale</option> </select>
<label> <input type="checkbox" name="rememberMe" value="true" /> Remember me </label>
<label> <input type="checkbox" name="acceptedTerms" value="true" required /> I accept the terms </label>
<button type="submit">Create account</button></form>type picks the keyboard and native picker; autoComplete drives autofill. The new-password token tells the password manager to offer a generated secret.
<form action={createAccount} method="post"> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="password">Password</label> <input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required />
<label htmlFor="organizationName">Organization name</label> <input id="organizationName" name="organizationName" type="text" required />
<label htmlFor="plan">Plan</label> <select id="plan" name="plan" defaultValue="free"> <option value="free">Free</option> <option value="pro">Pro</option> <option value="scale">Scale</option> </select>
<label> <input type="checkbox" name="rememberMe" value="true" /> Remember me </label>
<label> <input type="checkbox" name="acceptedTerms" value="true" required /> I accept the terms </label>
<button type="submit">Create account</button></form>Native constraints catch honest mistakes before a round-trip. Each is mirrored by a Zod rule, and that rule is what actually defends the system.
Wiring this up comes later: connecting action to a Server Action, authoring the Zod schema, and adding React hooks for pending states, inline errors, and optimistic updates.
External resources
Section titled “External resources”Google's free 23-part course on building forms — structure, autofill, validation, and accessibility, with a quiz at the end.
Every input type and attribute, with examples and behavior notes per type.
The authoritative list of autoComplete tokens the browser and password managers understand.
Silktide's 53-min deep dive on labels, grouping, and validation states — the accessibility half of the label contract.