HTML form submission6 min read
HTML form validation in the browser and on the server
Validate a form with required, type, pattern and min, style errors with :user-invalid, and enforce the same rules on the server with field definitions.
Use HTML's built-in constraints, such as required, type="email", min, max and pattern, so the browser blocks bad input before anything is sent. Then enforce the same rules on the server, because bots and scripts skip the browser entirely. With Ikiform, define the form's fields and every post is checked: a bad one gets 422 validation_failed with a message per field, which you can show with setCustomValidity.
The example is a workshop booking form. It has the kinds of fields where validation earns its keep: a team size with limits, a date that can't be in the past, a phone number in one format, and a booking code with a fixed shape.
What's different here
There are two validators, and they don't see the same requests. The browser checks only people who use your page, with scripts or without. The server checks everything, including bots posting straight to the endpoint and your own code calling form.submit(), which skips the browser's checks. The work is making both say the same thing.
- A person submits the form, and the browser checks the constraints in the HTML. An invalid field gets a message and nothing is sent.
- A valid post reaches Ikiform, which checks it against the form's field definition and stores typed values.
- A bot or script posts straight to the endpoint, so no browser check runs.
- Ikiform checks it against the same definition and refuses it with 422 before anything is stored.
The browser layer
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
ws-12.<form id="workshop" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="name">Your name</label>
<input id="name" name="name" autocomplete="name" maxlength="120" required />
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="team_size">People attending</label>
<input id="team_size" name="team_size" type="number" min="2" max="20" step="1" required />
<label for="date">Preferred date</label>
<input id="date" name="date" type="date" min="2026-11-02" required />
<label for="phone">Phone</label>
<input id="phone" name="phone" type="tel" autocomplete="tel" placeholder="+14155550123"
pattern="\+[1-9][0-9]{6,14}" title="International format, like +14155550123" />
<label for="code">Booking code</label>
<input id="code" name="code" pattern="WS-[0-9]{4}" title="Codes look like WS-1234" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Request the workshop</button>
</form>Each attribute maps to one validity state, which you can read from input.validity in the console:
| Attribute | Validity state | Notes |
|---|---|---|
required | valueMissing | For a radio group, one radio with required covers the group |
type="email", type="url" | typeMismatch | Loose: a@b passes as an email in browsers |
pattern | patternMismatch | Must match the whole value. Ignored on an empty optional field |
minlength, maxlength | tooShort, tooLong | Only checked after the person edits the value |
min, max | rangeUnderflow, rangeOverflow | Numbers, dates and times |
step | stepMismatch | step="1" refuses decimals |
| Unparseable input | badInput | Letters typed into a number field in some browsers |
setCustomValidity() | customError | Any message you set, until you set it back to empty |
Patterns in two engines
Browsers compile pattern with the v flag, which is stricter than the regular expressions most people write: a literal - inside a character class must be escaped, as in [a-z\-]. Ikiform compiles a field's pattern with the u flag and also matches the whole value. Write patterns that are valid in both, as the booking code above is, and test each one in the form preview before you publish it.
Styling errors
:invalid matches an empty required field the moment the page loads, so every field shows red before anyone types. :user-invalid matches only after the person has interacted with the field or tried to submit, which is almost always what you want:
<style>
#workshop :user-invalid {
border-color: #b42318;
}
</style>The server layer
Paste the definition above on the form's Fields tab. From then on, Ikiform validates every post against it, from any source, and stores coerced values: team_size as a number, phone without separators, date as YYYY-MM-DD. A post that breaks a rule is refused, nothing is stored, and the answer says which field failed:
| Field | Sent | Message |
|---|---|---|
team_size | 25 | Must be at most 20 |
team_size | 2.5 | Enter a whole number |
date | 2026-10-30 | Must be on or after 2026-11-02 |
phone | 415 555 0123 | Use international format, e.g. +14155550123 |
code | ws-12 | Codes look like WS-1234 |
website | Any value | Unknown field "website" |
The last row matters. With fields defined, Ikiform refuses any field that isn't in the definition, so a renamed input fails loudly instead of being stored under the wrong name. Fields starting with _ and the honeypot don't count. Every type and rule is in Fields and validation.
With a plain post
No code needed. A refused plain post shows Ikiform's hosted error page with the 422 status, each problem under your field's label, and a link to the page the post came from. The browser's Back button usually keeps what the person typed; the link reloads the form empty.
Showing server errors on the fields
With a script, feed each server message into the browser's own error UI. setCustomValidity marks the field invalid with that text, and reportValidity shows it as the usual bubble and focuses the first one:
const form = document.querySelector("#workshop");
for (const control of form.elements) {
control.addEventListener("input", () => control.setCustomValidity?.(""));
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceWith("Thanks. We'll confirm the date by email.");
return;
}
const fields = result.error.fields ?? {};
for (const [name, messages] of Object.entries(fields)) {
form.elements.namedItem(name)?.setCustomValidity?.(messages[0]);
}
if (!form.reportValidity()) return;
alert(result.error.message);
});- Clear the custom message on
input. A field with a custom error stays invalid until you set it back to an empty string, and then the person can't submit at all. - The browser's checks still run before the
submitevent, so the server only sees posts that passed them. - If the error has no
fields, such as429 rate_limited,reportValidity()returnstrueand the script falls back to the general message.
Checking server rules before sending
To catch server-only rules, like an email domain list, before the request, load the form's public definition and run the SDK's validate, which applies the same field rules and messages in the browser:
import { getSchema, validate } from "@ikiform/sdk";
const form = document.querySelector("#workshop");
const result = await getSchema("frm_xxxxxxxxxxxxxxxx");
const fields = result.ok ? result.schema.fields : [];
form.addEventListener("submit", (event) => {
const errors = validate(fields, new FormData(form));
for (const control of form.elements) control.setCustomValidity?.(errors[control.name] ?? "");
if (!form.reportValidity()) event.preventDefault();
});getSchema only exposes field definitions, which are public anyway: anyone can read them from /f/<formId>/schema.Check it works
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Submit the empty form. The browser stops it at Your name and nothing appears in the Network panel.
- Remove
maxfrom the team size in developer tools, enter25and submit. The plain post shows Ikiform's error page; the scripted one shows Must be at most 20 on the field. - Post with curl, skipping the browser:
curl -H "Accept: application/json" -H "Origin: https://example.com" -d team_size=abc https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx, with your own allowed domain as the origin. The answer is422with a message forteam_sizeand every missing required field. - Open Submissions in the dashboard. The new submission is there with every field you sent.
Gotchas
| Problem | Cause and fix |
|---|---|
| The form never submits after a server error | A custom validity message was never cleared. Reset it with setCustomValidity("") on input |
minlength doesn't fire on prefilled values | tooShort only applies after the person edits the field. The server rule catches it |
A pattern is ignored entirely | It's invalid under the v flag, and browsers skip an invalid pattern entirely. Escape - and other special characters inside classes |
| Errors show before anyone types | The CSS uses :invalid. Switch to :user-invalid |
422 validation_failed | The post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id. |
The full submission flow is in the overview, and Errors lists every code a post can get back.
Questions
No. It only runs in the browser on your page, and bots or scripts post directly to the endpoint. Validate on the server as well. With Ikiform, define the form's fields and every post is checked.
Call input.setCustomValidity(message) and then form.reportValidity(), which shows the message in the browser's usual bubble. Clear it with setCustomValidity("") when the person edits the field.
:invalid matches any field that fails its constraints, including empty required fields on page load. :user-invalid only matches after the person has interacted with the field or tried to submit.
422 validation_failed with nothing stored. JSON requests get error.fields, which maps each field id to its messages; plain form posts get a hosted error page that lists them by field label.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.