On this page
A normal form submission reloads the page. Submitting with JavaScript's fetch() keeps people on the page, so you can show a loading spinner, an inline success message, or a friendly error — and keep everything they typed if something goes wrong.
This guide builds a robust fetch-based submission step by step, in plain JavaScript.
Start with a form that works without JavaScript
Progressive enhancement first: the form should submit normally if your script fails to load.
<form id="contact" action="https://api.formclick.io/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
<input type="checkbox" name="botcheck" style="display: none;">
<label for="name">Name</label>
<input id="name" name="name" required>
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send</button>
<p class="status" role="status" aria-live="polite"></p>
</form>
The minimal version
const form = document.getElementById('contact');
form.addEventListener('submit', async function (event) {
event.preventDefault(); // stop the normal page submit
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form), // every named field, files included
});
const result = await response.json();
console.log(result); // { success: true, message: "…" }
});
Three important details:
new FormData(form)collects every field with aname— including checkboxes, selects and files — exactly as the browser would.- Don't set
Content-Typeyourself when sending FormData. The browser sets it, including the multipart boundary. Setting it manually breaks the request. Accept: application/jsonasks the endpoint for a JSON response instead of an HTML page.
FormData or JSON?
| FormData | JSON | |
|---|---|---|
| Files | Yes | No |
| Content-Type header | Set automatically | application/json |
| Best for | Real HTML forms | Data built in code (SPAs, apps) |
Sending JSON:
const data = Object.fromEntries(new FormData(form));
await fetch('https://api.formclick.io/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(data),
});
Note that Object.fromEntries keeps only the last value for repeated names (like several checkboxes with the same name). Use formData.getAll('name') for those.
The production-ready version
Real forms need loading states, error handling, a timeout and a way to prevent double submits:
const form = document.getElementById('contact');
const button = form.querySelector('button[type="submit"]');
const status = form.querySelector('.status');
form.addEventListener('submit', async function (event) {
event.preventDefault();
if (!form.reportValidity()) return; // run built-in validation
button.disabled = true;
button.textContent = 'Sending…';
status.textContent = '';
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
signal: AbortSignal.timeout(15000), // give up after 15 seconds
});
const result = await response.json().catch(function () { return {}; });
if (response.ok && result.success) {
form.reset();
status.textContent = 'Thanks! Your message has been sent.';
status.className = 'status ok';
} else {
throw new Error(result.message || 'Something went wrong.');
}
} catch (error) {
status.textContent = error.name === 'TimeoutError'
? 'The request timed out. Please try again.'
: 'Sorry, we could not send your message. ' + error.message;
status.className = 'status error';
} finally {
button.disabled = false;
button.textContent = 'Send';
}
});
What this handles
- Validation first —
reportValidity()shows the browser's messages. - No double submits — the button is disabled while sending.
- HTTP errors —
fetchonly rejects on network failure, so we checkresponse.okourselves. - Timeouts —
AbortSignal.timeout()stops a request hanging forever. - Accessibility — the status paragraph has
role="status"andaria-live, so screen readers announce the result. - Data safety — the form is only reset on success, so nothing is lost on error.
Styling the status message
.status { margin-top: 12px; font-weight: 600; }
.status.ok { color: #15803d; }
.status.error { color: #dc2626; }
Prefer not to write it yourself?
Formclick's script does all of this — loading state, success and error messages, file uploads — with one line:
<script src="https://cdn.formclick.io/formclick.min.js"></script>
Common mistakes
- Forgetting
event.preventDefault(), so the page reloads anyway. - Setting
Content-Type: multipart/form-datamanually with FormData. - Assuming
fetchthrows on 4xx/5xx responses — it doesn't. - Showing success before the response arrives.
- Putting secret API keys in front-end code. (A Formclick access key is fine — it can only submit to your form.)
Using a framework? See How to Integrate HTML Forms With React. Building on a static site? Read How to Handle Forms on Static Websites.



