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

Published 2026-10-25 · https://ikiform.com/guides/html-form-submission/validation

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.

Diagram: A post passing through browser validation and then Ikiform's field validation

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

Form: Book a workshop

- **Your name** (`name`, text, required)
- **Work email** (`email`, email, required)
- **People attending** (`team_size`, number, required)
- **Preferred date** (`date`, date, required)
- **Phone** (`phone`, phone)
- **Booking code** (`code`, text)

The definition Ikiform enforces. Try a team size of 25, a date before November 2, or a code like `ws-12`.

`workshop.html`:

```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:

| 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:

`workshop.html`:

```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:

| 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](https://ikiform.com/docs/fields.md).

### 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`:

```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](https://ikiform.com/docs/sdk.md)'s `validate`, which applies the same field rules and messages in the browser:

`workshop.js`:

```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

| 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](https://ikiform.com/guides/html-form-submission.md#validation), and [Errors](https://ikiform.com/docs/errors.md) lists every code a post can get back.

## Questions

### Is HTML form validation enough on its own?

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.

### How do I show a server error message on an input?

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.

### What is the difference between :invalid and :user-invalid?

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

### What does Ikiform return when a field fails validation?

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.

## More guides

- [HTML form submission](https://ikiform.com/guides/html-form-submission.md): What a browser does when a form is submitted: which fields it sends, how it encodes them, what the server should answer, and how Ikiform answers.
- [Without a page reload](https://ikiform.com/guides/html-form-submission/without-page-reload.md): Send a form with fetch and FormData so the visitor stays on the page, with field errors, a loading state, retries and a fallback when scripts fail.
- [The action attribute](https://ikiform.com/guides/html-form-submission/action-attribute.md): What a form's action attribute does, how relative and empty values resolve, how formaction sends one form to two places, and why static hosts answer 405.
- [Redirect after submit](https://ikiform.com/guides/html-form-submission/redirect-after-submit.md): Send visitors to your own thank-you page after a form posts: the 303 redirect, the _redirect field, which domains are allowed and what refresh does.
