HTML form submission

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.

Preet SutharFounder, Ikiform

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 post passing through browser validation and then Ikiform's field validationa person on your pagea bot or a scriptsubmitbrowserconstraintsrequired, type,min, patternIkiformfieldsthe same rules,enforcedstoredtyped valuesbubble onthe fieldnothing sentdirectPOSTno page, nochecksIkiformfields422validation_failednothing storedinvalidA post passing through browser validation and then Ikiform's field validationa person on your pagea bot or a scriptsubmitbrowserconstraintsrequired, type, min,patternIkiform fieldsthe same rules,enforcedstoredtyped valuesbubbleon thefieldnothingsentdirect POSTno page, no checksIkiform fields422validation_failednothing storedinvalid
  1. A person submits the form, and the browser checks the constraints in the HTML. An invalid field gets a message and nothing is sent.
  2. A valid post reaches Ikiform, which checks it against the form's field definition and stores typed values.
  3. A bot or script posts straight to the endpoint, so no browser check runs.
  4. Ikiform checks it against the same definition and refuses it with 422 before anything is stored.
The browser layer is for people. The server layer is the one that holds.

The browser layer

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

The definition Ikiform enforces. Try a team size of 25, a date before November 2, or a code like ws-12.
workshop.html
<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:

AttributeValidity stateNotes
requiredvalueMissingFor a radio group, one radio with required covers the group
type="email", type="url"typeMismatchLoose: a@b passes as an email in browsers
patternpatternMismatchMust match the whole value. Ignored on an empty optional field
minlength, maxlengthtooShort, tooLongOnly checked after the person edits the value
min, maxrangeUnderflow, rangeOverflowNumbers, dates and times
stepstepMismatchstep="1" refuses decimals
Unparseable inputbadInputLetters typed into a number field in some browsers
setCustomValidity()customErrorAny 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:

workshop.html
<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:

FieldSentMessage
team_size25Must be at most 20
team_size2.5Enter a whole number
date2026-10-30Must be on or after 2026-11-02
phone415 555 0123Use international format, e.g. +14155550123
codews-12Codes look like WS-1234
websiteAny valueUnknown 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:

workshop.js
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 submit event, so the server only sees posts that passed them.
  • If the error has no fields, such as 429 rate_limited, reportValidity() returns true and 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:

workshop.js
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();
});
Treat this as a convenience. The server is still the source of truth, and getSchema only exposes field definitions, which are public anyway: anyone can read them from /f/<formId>/schema.

Check it works

  1. Under the form's Allowed domains, add your site's domain, plus localhost while you develop. Leave it empty to accept posts from anywhere.
  2. Submit the empty form. The browser stops it at Your name and nothing appears in the Network panel.
  3. Remove max from the team size in developer tools, enter 25 and submit. The plain post shows Ikiform's error page; the scripted one shows Must be at most 20 on the field.
  4. 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 is 422 with a message for team_size and every missing required field.
  5. Open Submissions in the dashboard. The new submission is there with every field you sent.

Gotchas

ProblemCause and fix
The form never submits after a server errorA custom validity message was never cleared. Reset it with setCustomValidity("") on input
minlength doesn't fire on prefilled valuestooShort only applies after the person edits the field. The server rule catches it
A pattern is ignored entirelyIt's invalid under the v flag, and browsers skip an invalid pattern entirely. Escape - and other special characters inside classes
Errors show before anyone typesThe CSS uses :invalid. Switch to :user-invalid
422 validation_failedThe 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.