Skip to content
Chapter 17Lesson 5

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.

  1. The user fills in some labeled fields and clicks submit.
  2. The browser walks every control , reads each one’s name and value, and packs them into a FormData object.
  3. That FormData crosses the wire to your server.
  4. 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.

STEP 1 Labeled form
STEP 2 FormData
STEP 3 The wire
STEP 4 Zod → typed object
Acme sign-up form
Email
ada@acme.com name="email"
Password
•••••••• name="password"
The user fills labeled fields. Each input's name is the contract.
STEP 1 Labeled form
STEP 2 FormData
STEP 3 The wire
STEP 4 Zod → typed object
FormData
email "ada@acme.com"
password "••••••••"
On submit, the browser reads every control's name and value into a FormData object. The names become the keys.
STEP 1 Labeled form
STEP 2 FormData
STEP 3 The wire
STEP 4 Zod → typed object
Browser
email → "ada@acme.com" password → "••••••••"
Server
That FormData crosses the wire to the server, the same name/value pairs now in transit.
STEP 1 Labeled form
STEP 2 FormData
STEP 3 The wire
STEP 4 Zod → typed object
Zod schema
z.object({ email: z.email(), password: z.string(), })
Typed object
{ email: string, password: string, }
A Zod schema whose keys are the same strings validates it, producing a clean, typed 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>

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.

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

    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.

    1 / 1

    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.

    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.

    typeUse it forWorth knowing
    textNames, free single-line textThe default. maxLength/minLength bound the length.
    emailEmail addressesEmail keyboard on mobile; a loose @ shape check.
    passwordSecretsMasks the input. Pairs with autoComplete (next section).
    numberQuantities, amountsStill arrives as a string ("42"). Use min/max/step to bound it; maxLength is ignored here.
    telPhone numbersTelephone keypad on mobile. No format enforcement, since formats vary by country.
    urlLinks, websitesURL keyboard; a loose URL shape check.
    dateCalendar datesNative date picker. Value is an ISO string, yyyy-mm-dd.
    timeTimes of dayNative time picker.
    datetime-localA local date and timeCombined picker, no timezone.
    checkboxA boolean or a multi-pick optionSubmits its value only when checked (see the next section; this one bites).
    radioOne choice from a setSeveral radios share one name; only the checked one submits.
    fileFile uploadsUncontrolled, so React can’t set its value; read it from a ref. Needs multipart/form-data.
    hiddenCarrying data the user doesn’t editThe 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.

    Work email
    email
    Phone number
    tel
    Billing date
    date
    Account password
    password
    Company website
    url

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

    Control state
    I accept the terms
    FormData
    acceptedTerms "true"
    A checked checkbox submits its value under its name.

    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>

    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.

    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:

    • required blocks submit and shows a browser message when the field is empty.
    • type-based checks like email, url, and number enforce a loose shape for free.
    • min / max bound a number or a date to a range.
    • minLength / maxLength bound a string’s length. (maxLength is ignored on type="number"; use min/max there.)
    • pattern is 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.

    Honest
    User fills form
    Browser check required, type
    Accepted
    Scripted
    Scripted POST curl, script
    Browser check not run
    skipped
    Rejected bad data
    The server Zod safeParse The one checkpoint neither lane can skip
    Two checkpoints, one trusted. The browser check can be skipped; the server's Zod check cannot, so the server is the only place a rule is enforced.

    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.

    Improves UX (can be skipped) A client-side hint to honest users
    Enforces the rule (server-side) The guarantee that actually defends the system
    the required attribute
    type="email" on the input
    a pattern regex on the input
    minLength on the input
    the Zod schema’s z.email()
    a server safeParse of the FormData

    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.

    1 / 1

    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.