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.
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.
- The browser runs its constraint check first. The submit event only fires when the form is valid.
- Your listener calls preventDefault, so the tab doesn't navigate.
- It sends the form's data with fetch and asks for JSON.
- On 201, it replaces the form with a status message and moves focus to it.
- On a 422 or a network failure, it shows the message next to the field, focuses it and re-enables sending.
| The browser did this | Now your script must |
|---|---|
| Showed a loading state in the tab | Show one, and announce it to screen readers |
| Made double clicks mostly harmless | Ignore a second submit while one is in flight |
| Showed the error page | Map error.fields to each input |
| Showed a network error page | Catch failed requests and timeouts |
| Moved focus to the new page | Move 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.
<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
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 ownnameandvalueif it has one. Build it before you change anything in the form.- The button gets
aria-disabled, notdisabled. Thesendingflag does the real blocking, and the button stays focusable so screen reader users don't lose their place. - No
Content-Typeheader. With aFormDatabody the browser setsmultipart/form-datawith the right boundary. Accept: application/jsonmakes Ikiform answer in JSON. Afetchcall sendsSec-Fetch-Mode: cors, which would pick JSON anyway, but the header makes the intent plain.- One idempotency key per page load, sent as the
_idempotencyfield. 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. TheIdempotency-Keyheader 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:
<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.
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
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Submit Yes with no comment. The form turns into Thanks, that helps. and the Network panel shows
201. - Paste more than 2,000 characters into the comment, remove
maxlengthin developer tools, and send. The message appears under the comment and focus moves there. - Double-click Send. The dashboard shows one submission, not two.
- Turn JavaScript off and submit. You land on the
_redirectpage. - Set the throttling to Offline and send. The status line says it couldn't reach the server, and Send works again.
Gotchas
| Problem | Cause and fix |
|---|---|
| A field is missing from the post | It 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 submission | The code set Content-Type: multipart/form-data by hand, with no boundary. Delete the header |
| The page reloads anyway | The 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() throws | The request went somewhere that answered HTML, usually a relative action. Read form.action in the console; it must be your Ikiform URL |
403 challenge_required | The 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 spam | The 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
Add a submit listener and call event.preventDefault() as its first line, then send the data yourself with fetch. Listen on the form's submit event, not the button's click, so the Enter key is covered too.
No, and you shouldn't. The browser sets multipart/form-data with the boundary string. A header you write yourself has no boundary, and the server can't parse the body.
Yes, as long as you listen for the submit event, which only fires after the browser's constraint check passes. Calling form.submit() from code skips both the check and the event; form.requestSubmit() runs them.
Ignore submits while a request is in flight, and send one idempotency key per page load, as the _idempotency field or the Idempotency-Key header. If a retry still reaches Ikiform, it replays the first answer instead of storing the submission again.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.