Forms as a contract with the server
Author native HTML forms whose name attributes form the FormData contract a server reads and validates.
You sit down to build a sign-up form for Acme, your invoicing app. If you learned web development from a tutorial in the last decade, your fingers already know what to type: a useState for the email, another for the password, a value and onChange wired to each input, and a submit handler that reads all that state back out and assembles an object to send. That is roughly thirty lines of plumbing per form, and you have written all of it before validating a single field.
There is another way to start, and it is the one an experienced engineer reaches for: a plain <form> of labeled inputs, each carrying a name, a <button type="submit">, and a server that reads the submission and validates it. There is no useState and no submit handler. The browser already knows how to collect the fields and send them, so those thirty lines reimplement a feature the platform ships for free.
This lesson builds that second instinct. We are not wiring up form behavior yet: submitting to a server, showing pending spinners, and rendering errors all come later. Today is about the markup, which means the elements, the attributes, and the one idea everything hangs off: a form is a contract the server reads. The set of name attributes you put on the form and the keys your server expects are the same list, and an experienced engineer writes them together. By the end you will be able to hand-author the full Acme sign-up form. Every field gets the right element, a name, a <label>, the right keyboard, and cheap validation, and you will be able to explain why the submit button works without a line of JavaScript.
A form is a contract, not a pile of inputs
Section titled “A form is a contract, not a pile of inputs”Before we look at any single element, hold the whole round-trip in your head as four steps. The sequence is short, and everything else in the lesson hangs off it.
- The user fills in some labeled fields and clicks submit.
- The browser walks every control in the form, reads each one’s
nameand current value, and packs them into aFormDataobject. - That
FormDatacrosses the wire to your server. - On the server, a Zod schema validates the
FormDataand hands you back a clean, typed object.
The key idea is that 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 those two lists agree, the form round-trips with almost no glue code between them. When they disagree, because of a typo or a rename on one side only, a field the user dutifully filled silently never arrives. That shared list of strings is the contract, and it is why an experienced engineer writes the form and the schema in the same sitting, as one design, rather than building the form and then figuring out how to read it.
The following diagram makes the contract literal. Drag through the four steps and keep your eye on the name strings: they are the thread that runs from the markup all the way to the validated object.
One more idea is worth naming before we touch markup, because it changes how you weigh every decision in this lesson. A native HTML form is progressive enhancement by default. The round-trip you just saw happens whether or not your JavaScript bundle has loaded, because the browser submits the form on its own. Everything React adds to forms later, such as richer pending states, inline errors, and optimistic updates, is built on top of this working baseline rather than replacing it. Keep that order in mind: the platform default is the floor, and the fancier tools are upgrades you add when a form earns them.
The wire between the browser and your server, whether that is a Server Action or a plain POST endpoint, is a later chapter’s job. For now, just know the boundary is there and that both sides of it agree on the name strings.
The <form> element and the submit path
Section titled “The <form> element and the submit path”The contract needs a container, and that is the <form> element. It does two jobs: it marks which controls belong together, and it owns what happens on submit. Three of its attributes are worth understanding.
action says where the contract is sent. In plain HTML it is a URL string. In a 2026 React app it is usually a function, the Server Action, and React wires up the network call for you, as in <form action={createAccount}>. That is the default you will reach for. Building it out is a later chapter, so for now just recognize the shape. If you omit action, 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, which is right for a search or filter form, where the submission is a navigation you might bookmark or share. With post, the values travel in the request body, hidden from the URL. The decision 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 will almost never touch it. The default handles ordinary fields. There is exactly one reason to change it: the moment your form contains an <input type="file">, switch to multipart/form-data so the file’s bytes can ride along. It is the file-upload knob and nothing else.
The submit path needs no JavaScript. A <button type="submit"> inside the form, or simply pressing Enter while focused in a text field, triggers the browser’s native submit. The browser serializes the named controls and sends them according to method and action. You met type="submit" in the previous lesson; what matters here is why every button in a form needs its type spelled out. A button with no type inside a <form> defaults to submit, so a “Cancel” or “Show password” button you meant to be inert will quietly fire the form instead. Declare type on every button and that whole class of bug disappears.
Here is the Acme sign-up form as a bare shell. It is deliberately incomplete: the inputs have no labels and no names yet, because those are the contract, and the contract is what the next two sections cover. Think of this block as a seed we will grow field by field.
<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”If you take one attribute from this lesson, take this one. name is the key under which a control’s value shows up in FormData. The relationship is simple and has no middle ground: an input with a name contributes one entry to the submission, and an input without a name contributes nothing. Not an empty string, not a null, but nothing at all. The control is invisible to the submission.
That is the single most common forms bug, and it is hard to catch precisely because it is silent. Picture a user typing their organization name into a field, seeing it on screen, and clicking submit, while the value never reaches your server because someone forgot the name. No error fires. The form appears to work. The data is just gone. Once you internalize that name is the key, this bug stops happening to you, because you stop treating name as optional decoration and start treating it as the wire the value travels down.
So how should you name things? In a codebase built on Server Actions and Zod, which is the stack this course teaches, this is a real decision with a house rule. The schema keys ultimately derive from your database columns, which TypeScript reads as camelCase, so the name attributes should be camelCase too. name="organizationName" lines up exactly with the organizationName key in your Zod schema and the organizationName field on your typed object: one vocabulary across the form, the schema, and the table. Kebab-case names like name="organization-name" are perfectly legal HTML, but they force you to translate at the boundary, mapping organization-name back to organizationName somewhere. That translation layer is friction you can delete by naming the field organizationName from the start.
Now make the silent-drop bug something you have felt rather than just read about. The following exercise gives you a small Acme form wired against a Zod schema, but the contract is broken in three places: two inputs are missing their name entirely, and one has a name that doesn’t match the key the schema expects. Your job is to repair the contract 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 was dropping both from the submission silently: the user fills them, clicks submit, and the values never arrive. Adding name="email" and name="password" puts each value on the wire under the key the schema reads. The organization field did submit, but under org-name, a key the organizationName schema slot never looks for, so it was silently ignored too. Renaming it to name="organizationName" makes one vocabulary run across the form and the schema. Notice that the id and the name now hold the same string on each input. That is convention, not a requirement: the id links the <label>, and the name keys the server.
Labels are a second contract, with the user and their tools
Section titled “Labels are a second contract, with the user and their tools”A form signs a second contract, and <label> is where it signs. It is tempting to read a label as decoration, the little word sitting next to the box, but it is load-bearing infrastructure for three different audiences. For a screen-reader user, the label is what gets announced when focus lands on the field; without it, the input is “edit text, blank” and the user has no idea what to type. For someone with a motor impairment, the label is a click target: tapping the word “Email” focuses the input, which is a much larger target than the box alone. And for the password manager or browser autofill engine, the label text is a strong signal for what the field holds. A labeled field is one the whole ecosystem can understand.
There are two ways to associate a label with 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 association is the nesting itself:
<label> Email <input name="email" type="email" /></label>Both are valid. The one to reach for is the explicit htmlFor/id pairing, for a practical reason: it survives refactors. The link holds no matter how the markup moves around, so you can wrap the input in three more <div>s and the association doesn’t break. The form libraries you will meet in a later chapter are built around id-based wiring too. (Recall from earlier in this chapter that htmlFor is just the JSX spelling of HTML’s for, which is a reserved word in JavaScript.)
One distinction here trips up nearly everyone, because two attributes look like they do the same job and do not. 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. This is the DOM link: it connects the announced text to the box, and it is what makes clicking the word “Email” focus the input.
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />The input’s id is the other end of that link. An id must be unique on the whole page, because it is the field’s DOM address. The htmlFor and the id share a color here because they are one connection.
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />The name is a different contract entirely. It is the key this value lands under in FormData, the wire to the server rather than to the label. names must be unique within the form, except for radio groups, which deliberately share one.
<label htmlFor="email">Email</label><input id="email" name="email" type="email" />And type is a third concern: the UX. It picks the on-screen keyboard and a loose browser check. Three attributes, three jobs: id links the label, name keys the server, and type shapes the input. The trap is that id and name so often hold the same string ("email" here) that they look interchangeable until a radio group pulls them apart.
We will reach the radio-group case that separates them shortly. For now, hold the two ideas apart: id is the field’s address for the label; name is its key for the server.
One more thing a <label> is not is a placeholder. A placeholder is the grey hint text inside an empty input, and reaching for it instead of a label is a dated anti-pattern. It vanishes the instant the user starts typing, so it is gone exactly when someone might want to re-check what the field was for. Screen readers treat it inconsistently, and its contrast is usually too low to read comfortably. A placeholder can supplement a label with an example (“e.g. ada@acme.com”), but it can never replace one.
Typed inputs: the type attribute is a UX contract
Section titled “Typed inputs: the type attribute is a UX contract”The <input> element wears many hats, and the type attribute chooses which one. It picks the on-screen keyboard the mobile user sees, the autofill suggestions the browser offers, the native picker (a date wheel, a color swatch), and a layer of cheap validation. What it does not do is change the kind of value that reaches your server, and this is the part that catches everyone. Every input value arrives at the server as a string, no matter the type. This is the other half of our spine: type carries the UX, the same way name carries the contract.
The SaaS-relevant types are lookup material, so you will return to this table rather than memorize it. 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. |
A note on date: the value is a plain ISO string like "2026-06-01", and proper date handling on the JavaScript side has its own chapter much later. For now, an ISO string is all you need to know it sends. And type="submit" does technically exist as an input, but prefer <button type="submit">, because a button can hold rich content like an icon or a spinner, where an input submit can only hold a flat string label.
Two facts from that table earn their own emphasis because they actively trip people up.
First, every input value is a string in FormData, even type="number". The user types 42, and your server receives the string "42", not the number 42. The coercion happens on the server, where your Zod schema turns it into a real number (the tool for that is z.coerce.number(), and its chapter comes later). If you ever expect type="number" to hand JavaScript an actual number, this is the assumption to unlearn now.
Second, type is UX, not a server guarantee. type="email" nudges the mobile keyboard and runs a friendly browser check, but it does not force a valid email to arrive. A scripted client, or a malicious one, can POST your endpoint with anything at all and never touch your <input>. The last section of this lesson returns to this, but the seed to hold now is that type is a courtesy to honest users, not a barrier against dishonest ones.
Here is a quick drill to make the “pick type by the data’s meaning” habit stick. Match each Acme form 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.
emailteldatepasswordurlBeyond <input>: textareas, selects, and grouped controls
Section titled “Beyond <input>: textareas, selects, and grouped controls”<input> is not the whole vocabulary. A few other controls round it out, and the through-line for every one of them is the same question: what does this contribute to FormData? That is where the contract gets subtle, so we will tie each element straight back to the key/value model.
<textarea> is multi-line text, such as a note on an invoice or a description. It takes a name like any control and a rows attribute for its visible height. The one React-specific wrinkle is that where plain HTML puts a <textarea>’s initial value between its tags, React reads it from a defaultValue prop instead. (defaultValue is the uncontrolled spelling; the controlled version is a later chapter.)
<textarea name="notes" rows={4} defaultValue="" /><select> and <option> build a dropdown. The name goes on the <select>, and what lands in FormData is the value of whichever <option> is chosen. Give every option an explicit value: if you omit it, the option submits its visible text content instead, which couples your data to your copy. Here is Acme’s plan picker:
<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, at which point the server reads them with FormData.getAll(name) rather than .get(name). The single-select dropdown above is the common case.
The checkbox is the trap that catches everyone. An <input type="checkbox"> contributes its value to FormData only when it is checked. When it is unchecked, it contributes nothing: not "false", not the key with an empty value, but no entry at all. The key is simply absent from the submission. And if you don’t set a value, a checked box submits the string "on", which is rarely what you want, so reach for an explicit value:
<input type="checkbox" name="acceptedTerms" value="true" />This checked-or-absent asymmetry is exactly why a checkbox can’t be read naively as a boolean on the server: “missing key” has to be understood as “false,” which is a small coercion your Zod schema handles in a later chapter.
Radio groups run on one rule, and it is the whole mechanic: 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 case promised earlier where id and name come apart: every radio in the group 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 be the first child, names the group, and a screen reader announces it before each control inside. When several controls answer one question, this is the element to reach for rather than a <div>. (Its default border is stripped by the base CSS reset you will meet in the next chapter, so it won’t look boxed-in by default.)
The non-obvious part of all this is what actually ends up in the submission for the tricky controls. The following tabs show each one as a before-and-after: the control’s state on the left, the resulting FormData on the right, including the case where the answer is no entry at all.
Now predict it yourself. The following snippet has a checked checkbox, an unchecked checkbox, and a radio group. Before you reveal the answer, work out exactly which entries the submitted FormData contains.
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, so newsletter=yes. The radio group shares one name, and only the checked member submits, once — billing=yearly; the monthly radio contributes nothing. The trap is acceptedTerms: it is unchecked, so it submits nothing at all — not "false", not an empty value, the key is simply absent from the submission. That checked-or-absent asymmetry is exactly why a checkbox can’t be read as a naive boolean on the server; “missing key” is what your Zod schema has to read as “false.”
autoComplete: the autofill contract
Section titled “autoComplete: the autofill contract”Autofill is invisible right up until it misfires, and a smooth autofill is something experienced engineers actually tune, because it directly lifts conversion on a sign-up form. If name is the contract with your server, autoComplete is the contract with the autofill engine: the attribute the browser and password managers read to offer the right saved value and to know what to store when the user enters a new one.
You set it to a semantic token that names what the field holds. The values you will reach for in a SaaS app are email, username, current-password, new-password, given-name, family-name, name, organization, street-address, postal-code, country, and tel. (There is also a credit-card family, cc-number, cc-name, and cc-exp, worth recognizing when you build a billing form.)
The password pair is the one to understand rather than just copy. autoComplete="current-password" on a sign-in form tells the password manager “fill the saved password here.” autoComplete="new-password" on a sign-up or change-password form tells it “this is a new secret, so offer to generate a strong one and save it.” Same input type, opposite intent, and the token is how the browser tells them apart. Get this pair right and password managers do exactly the helpful thing; get it wrong and they fill a sign-up form with the user’s existing password.
That brings us to the anti-pattern. autoComplete="off" on a password field is a regression, not a security measure: it fights the password manager your users depend on, nudging them toward weaker, reused, hand-typed passwords. Use the semantic token instead. The narrow legitimate use for off is a genuinely one-time, never-reused value. A two-factor code field is the usual real example, because there is nothing to save and nothing to fill.
The following two tabs show the same email and password fields without and with the autofill tokens, so you can see exactly what changes. Fittingly for an invisible feature, it is just a couple of attributes.
<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 form works, but 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" lets the browser offer the user’s saved address; autoComplete="new-password" tells the password manager this is a sign-up, so it offers to generate and save a strong password rather than fill an old one.
Native constraints are UX, not security
Section titled “Native constraints are UX, not security”Everything so far has been building toward one boundary, the one to internalize before you ship a form. HTML gives you a set of validation attributes that run in the browser, before the form is sent. They are genuinely useful: they catch honest mistakes early and save the user a pointless round-trip. But they are not, and can never be, a security boundary. The principle to hold onto is this: native constraints make the form pleasant, and the server-side Zod schema makes it safe. Conflating the two is the classic junior security hole.
The constraints themselves are cheap to add (their depth is a later chapter):
requiredblocks the native submit and shows a localized browser message if the field is empty.type-based checks meanemail,url, andnumberenforce a loose shape for free.min/maxbound a number or a date to a range.minLength/maxLengthbound a string’s length. (Remember thatmaxLengthis ignored ontype="number"; usemin/maxthere.)patternis a regex the value must match. It is worth recognizing but rare to reach for in 2026, because the same rule reads far more legibly as a Zod schema on the server, and the client check is only a courtesy anyway.
Here is the claim the whole lesson has been pointed at, stated plainly: client-side constraints can always be bypassed. DevTools can delete the required attribute. A script can POST your endpoint and never load the page. A browser with JavaScript disabled skips the checks. A curl command doesn’t even have a form. So your required, your type="email", and your pattern are each a hint to honest users, and none of them defends your system. Every constraint you put on the markup is mirrored by a rule in your server-side Zod schema, and the Zod rule is the one that actually enforces it. The browser check is there to be fast and friendly; the server check is there because it is the only one you can trust. So the experienced engineer’s habit is to validate on the client for the user and on the server for the system, and never to trust the client.
The following diagram makes that geometry concrete. There are two paths to your server. The honest path goes through the browser’s constraint check first, then the server’s Zod check. The hostile path skips the browser entirely. Notice that both paths still hit Zod, which is the whole point.
Here is a short sorting exercise to lock in which side of that boundary each tool lives on. Drop each item into the bucket that describes it.
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 FormDataPutting the contract together
Section titled “Putting the contract together”Here is the whole Acme sign-up form, assembled, with every field labeled and named in camelCase, typed by meaning, autocompleted, and lightly constrained, and the submit button that fires the whole thing. Read it as the contract made complete: each name is a key your server will read, each <label> is a promise to the user, each type shapes the input, and each constraint is a courtesy the server will re-check anyway.
<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 container and the submit path. On submit, the contract is sent to createAccount as a post, and everything inside the <form> rides along.
<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. This set of name strings is the exact list your server’s Zod schema expects: the form and the schema are one vocabulary, written together.
<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, and the explicit htmlFor/id link survives refactors. (The “remember me” and terms checkboxes use the implicit form, with the <input> nested 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>The UX layer: type picks the keyboard and native picker, and autoComplete drives autofill. The new-password token tells the password manager this is a sign-up, so it offers to generate a strong 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>Cheap native constraints that catch honest mistakes before a round-trip. Every one is mirrored by a rule in the server-side Zod schema, and the Zod rule is the one that actually defends the system.
Step back, and one through-line holds the whole lesson together: the name attributes are the keys, the browser serializes the named controls into FormData, the server validates that FormData with a Zod schema whose keys are the same strings, and native constraints are a courtesy to the user, never a guarantee to the system. Write the form and the schema as one design and the round-trip almost wires itself.
What you have not built yet is the wiring, and that is deliberate. Connecting action to a Server Action and reading the FormData on the server is a later chapter; authoring the Zod schema that validates it is another; and the React hooks that layer richer pending states, inline errors, and optimistic updates on top of this native baseline come after that. You now have the surface those chapters wire onto, and the judgment to know that the markup you wrote here is the contract while everything else is enhancement.
External resources
Section titled “External resources”This lesson deliberately kept the attribute reference short. The resources below go deeper, from the canonical specs to a free interactive course and an accessibility deep-dive.
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.