HTML form submission

HTML form submission6 min read

Submit an HTML form without a page reload

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.

Preet SutharFounder, Ikiform

Listen for the form's submit event, call preventDefault(), and send new FormData(form) with fetch and an Accept: application/json header. Ikiform then answers with JSON instead of a redirect: 201 on success, or field errors you show next to each input. Keep a real action and method on the form so it still submits the plain way if your script never runs.

The example here is a Was this page helpful? box at the bottom of a docs page. It's the classic case for staying on the page: the reader is mid-task, and sending them to a thank-you page to record a single click would be absurd. The same pattern works for any form.

What changes when you take over the submit

A plain form post gives you a lot for free: the browser shows a loading indicator, stops double clicks from mattering much, handles the back button and shows whatever page comes back. The moment you call preventDefault(), all of that becomes your job. The fetch call itself is three lines; the rest of this page is the work the browser used to do.

What a script does when it submits a form with fetch instead of letting the browser navigatesubmit eventafter thebrowser's owncheckspreventDefault()no navigationfetchFormDataAccept:application/json201swap in athank-you linemessage onthe fieldfocus movesthere, buttonworks again422 or network errorWhat a script does when it submits a form with fetch instead of letting the browser navigatesubmit eventafter the browser'sown checkspreventDefault()no navigationfetch FormDataAccept:application/json201swap in a thank-youlinemessage onthe fieldfocus movesthere, buttonworks again422 or network error
  1. The browser runs its constraint check first. The submit event only fires when the form is valid.
  2. Your listener calls preventDefault, so the tab doesn't navigate.
  3. It sends the form's data with fetch and asks for JSON.
  4. On 201, it replaces the form with a status message and moves focus to it.
  5. On a 422 or a network failure, it shows the message next to the field, focuses it and re-enables sending.
The browser's validation still runs before your code. Everything after preventDefault is yours to handle.
The browser did thisNow your script must
Showed a loading state in the tabShow one, and announce it to screen readers
Made double clicks mostly harmlessIgnore a second submit while one is in flight
Showed the error pageMap error.fields to each input
Showed a network error pageCatch failed requests and timeouts
Moved focus to the new pageMove focus to the result or the first bad field

The form

Three fields: a required yes or no, an optional comment, and the page address filled in by the script. Try the definition below; it's what Ikiform enforces on the server, so a comment over 2,000 characters gets a 422 here too.

Preview

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

Was this page helpful?
Paste this on the form's Fields tab. With fields defined, Ikiform also refuses any field it doesn't know.
feedback.html
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>Was this page helpful?</legend>
    <label><input type="radio" name="helpful" value="yes" required /> Yes</label>
    <label><input type="radio" name="helpful" value="no" /> No</label>
  </fieldset>

  <label for="comment">What was missing?</label>
  <textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
  <p id="comment-error" hidden></p>

  <input type="hidden" name="page" />
  <input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="feedback-status" role="status" tabindex="-1"></p>
</form>

<script type="module" src="/page-feedback.js"></script>

The form is complete without the script. With JavaScript off or blocked, it posts the plain way, Ikiform answers 303 and the reader lands on the _redirect page. The script only improves that.

The script

page-feedback.js
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;

pageInput.value = location.href;

function showFieldErrors(fields) {
  for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
  for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
  let first = null;
  for (const [name, messages] of Object.entries(fields)) {
    const control = form.elements.namedItem(name);
    const hint = form.querySelector(`#${name}-error`);
    if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
    if (hint) {
      hint.textContent = messages[0];
      hint.hidden = false;
    }
    first ??= control instanceof RadioNodeList ? control[0] : control;
  }
  first?.focus();
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (sending) return;
  const body = new FormData(form, event.submitter);
  body.set("_idempotency", idempotencyKey);
  sending = true;
  button.setAttribute("aria-disabled", "true");
  status.textContent = "Sending...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
      signal: AbortSignal.timeout(15000),
    });
    const result = await response.json();

    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Thanks, that helps.";
      status.focus();
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    if (result.error.fields) {
      status.textContent = "";
      showFieldErrors(result.error.fields);
    } else if (response.status === 429 && response.headers.has("Retry-After")) {
      const wait = response.headers.get("Retry-After");
      status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
    } else {
      status.textContent = result.error.message;
    }
  } catch {
    status.textContent = "Couldn't reach the server. Check your connection and send again.";
  } finally {
    sending = false;
    button.removeAttribute("aria-disabled");
  }
});
  • new FormData(form, event.submitter) reads every named control, plus the clicked button's own name and value if it has one. Build it before you change anything in the form.
  • The button gets aria-disabled, not disabled. The sending flag does the real blocking, and the button stays focusable so screen reader users don't lose their place.
  • No Content-Type header. With a FormData body the browser sets multipart/form-data with the right boundary.
  • Accept: application/json makes Ikiform answer in JSON. A fetch call sends Sec-Fetch-Mode: cors, which would pick JSON anyway, but the header makes the intent plain.
  • One idempotency key per page load, sent as the _idempotency field. If the reader sends, the network drops the answer and they send again, Ikiform replays the first answer instead of storing a second submission. A refused post never claims the key, so a corrected resend goes through. The Idempotency-Key header does the same, but a custom header makes the browser send a preflight request first. JavaScript and fetch has the exact rules.
  • AbortSignal.timeout(15000) turns a hung request into the catch branch instead of an endless Sending....
  • The status line has role="status", so its text is read out without moving focus. On success, focus moves to it because the fields around it are gone.

When the form has a human check

If the form's Spam check is set to Ikiform, a fetch post without a Turnstile token gets 403 challenge_required. The script above handles it by calling form.submit(). That method skips the submit listener and posts the plain way, and for a plain post Ikiform shows its own short check page, then continues to your _redirect page. The reader leaves the page only when the check is on.

To stay on the page even then, get a token first with challenge(formId) from the SDK and pass it to submit(). The spam docs show the flow.

The no-JavaScript alternative

Before fetch, the trick was to target a hidden iframe, so the response loads there and the page stays put:

Old trick
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
  ...
</form>

It still works, but the iframe holds a page on ikiform.com, and the browser won't let your script read a cross-origin frame. You can't tell success from a validation error, so the reader gets no feedback either way. Use it only where a script truly can't run.

A server framework can give you no reload without client fetch code: Next.js server actions, SvelteKit's use:enhance and React Router's <Form> post the plain way before the page hydrates and without a reload after. The overview shows each.

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 Yes with no comment. The form turns into Thanks, that helps. and the Network panel shows 201.
  3. Paste more than 2,000 characters into the comment, remove maxlength in developer tools, and send. The message appears under the comment and focus moves there.
  4. Double-click Send. The dashboard shows one submission, not two.
  5. Turn JavaScript off and submit. You land on the _redirect page.
  6. Set the throttling to Offline and send. The status line says it couldn't reach the server, and Send works again.

Gotchas

ProblemCause and fix
A field is missing from the postIt was disabled when FormData was built. Disabled controls are never sent. Build the data first, or lock inputs with readonly
400 invalid_body or an empty submissionThe code set Content-Type: multipart/form-data by hand, with no boundary. Delete the header
The page reloads anywayThe listener threw before preventDefault(), or the click came before the script loaded. Call preventDefault() first; an early click posts the plain way, which still works
response.json() throwsThe request went somewhere that answered HTML, usually a relative action. Read form.action in the console; it must be your Ikiform URL
403 challenge_requiredThe form's human check is on. Fall back to form.submit() as above, or get a token with challenge()
403 origin_not_allowed, or every post lands in spamThe page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed.

Questions

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