How to Submit a Form with JavaScript fetch()
Submit a form with JavaScript fetch() and no page reload: intercept submit, POST the data, and handle the JSON response with success and inline field errors.
The ShipMyForm team
· 3 min read
A plain HTML form does a full page navigation when you submit it: the browser
POSTs the fields and loads whatever the server sends back. That's fine, but most
of the time you'd rather keep the visitor on the page and show an inline "thanks"
or, if something's wrong, an error next to the field that needs fixing. That's
what submitting with fetch() gives you.
The catch is that fetch() is only the browser half. It still needs somewhere to
POST to. This guide uses ShipMyForm as that
endpoint so there's no server for you to write, but the pattern — intercept the
submit, POST the data, handle the response — is the same wherever you send it.
The baseline: a plain form POST
Here's the starting point. It works with zero JavaScript, but it navigates away on submit.
<form action="https://shipmyform.com/f/YOUR_FORM_ID" method="POST">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>Submit it with fetch() instead
Add a submit listener, stop the default navigation with preventDefault(), and
send the form's data with fetch(). The one important detail: send the header
Accept: application/json so the endpoint replies with a JSON body instead of
redirecting.
<form id="contact" action="https://shipmyform.com/f/YOUR_FORM_ID" method="POST">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
<script>
const form = document.querySelector("#contact");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const data = await res.json();
if (data.ok) {
form.reset();
// show your success UI
}
});
</script>new FormData(form) collects every named input for you, and the browser sets the
correct multipart/form-data content type automatically — including any
<input type="file">. A successful submission comes back as { "ok": true }.
Leaving action and method="POST" on the form means it
still works if JavaScript fails to load or is disabled — the browser falls back
to a normal POST. Reading form.action in the script also saves you
from hard-coding the URL twice.
Handle the states: loading, success, error
A good submit UX disables the button while the request is in flight and tells the visitor what happened. Track a little state and reflect it in the DOM.
const form = document.querySelector("#contact");
const button = form.querySelector("button");
const status = document.querySelector("#status");
form.addEventListener("submit", async (e) => {
e.preventDefault();
button.disabled = true;
button.textContent = "Sending…";
try {
const res = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const data = await res.json();
if (data.ok) {
form.reset();
status.textContent = "Thanks — we'll be in touch.";
} else {
status.textContent = data.message ?? "Something went wrong.";
}
} catch {
// Network error (offline, DNS, CORS). data never arrived.
status.textContent = "Couldn't reach the server. Please try again.";
} finally {
button.disabled = false;
button.textContent = "Send";
}
});Show per-field validation errors
This is where server responses earn their keep. If the form has
validation rules, an invalid submission comes
back as HTTP 422 with a fields object — field name to message. Render each
one beside its input.
const res = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const data = await res.json();
if (res.status === 422 && data.error === "validation") {
for (const [name, message] of Object.entries(data.fields)) {
const field = form.querySelector(`[name="${name}"]`);
field?.closest(".field")?.querySelector(".error")?.replaceChildren(message);
}
return;
}Because these checks run on the server, they hold even when a client skips the
browser's own required / type="email" validation — so the messages you show
are backed by a real gate, not just a hint.
The response shapes to expect
Handle these three cases and you've covered a form:
| Result | Status | Body |
|---|---|---|
| Success | 200 | { "ok": true } |
| Validation failed | 422 | { "ok": false, "error": "validation", "fields": { … } } |
| Other rejection | 4xx | { "ok": false, "error": "<code>", "message": "…" } |
The error code on the third row tells you what happened — for example
rate_limited (with a retryAfter in seconds), origin, or too_large — so you
can react in code instead of showing a generic failure.
Prefer a JSON body? Set Content-Type: application/json and send
JSON.stringify(values). FormData is usually simpler because it
reads the form for you and handles file inputs, but both are accepted.
A couple of gotchas
- Reserved fields. Name an input
_replytoto set the reply-to on the notification email,_subjectto set its subject, and add a hidden, empty_gotchahoneypot to catch bots. These are read, not stored as data. - CORS. Cross-origin
fetch()only works from origins the form allows. Add your site's domain to the form's allowed domains so the browser accepts the response; a request from an un-allowed origin is rejected. - Don't forget
name.FormDataonly includes inputs that have anameattribute. A field with nonameis never sent.
The @shipmyform SDK wraps all of this in a submit()
function and framework hooks (React, Vue, Svelte, Solid) that track submitting /
succeeded / error state for you. The no-dependency approach above works
identically — see the
JavaScript SDK.
Next steps
- Doing this in a component? See Next.js form handling.
- Add server-side rules: how to add validation to your form.
- New to the idea? Start with what a form backend is.
- Start free — 100 submissions a month, no card required.
Frequently asked questions
- How do I submit a form without reloading the page?
- Add a submit handler, call event.preventDefault() to stop the browser's navigation, and send the data with fetch(). Include the header Accept: application/json so the endpoint returns JSON instead of redirecting, then update the page from the response. A full example is in this guide.
- Do I need a backend to submit a form with fetch()?
- No. fetch() is a browser API, so it needs somewhere to POST to. With a form backend like ShipMyForm you point the request at your endpoint URL and it stores, spam-filters, validates, and routes the submission. You do not write or host a server.
- Why does my form redirect instead of returning JSON?
- By default the endpoint replies with a 302 redirect so plain HTML forms land on a thank-you page. To get a JSON body back for fetch(), send the request header Accept: application/json. Then a success is { ok: true } and errors carry an error code you can handle in JavaScript.
- How do I show a message next to the field that failed?
- On a 422 response with error: "validation", read the fields object — a map of field name to message — and render each message beside the matching input. ShipMyForm validates on the server, so these errors are reliable even if a client skips the browser's own checks.
Related guides
How to Add Validation to Your Form
Reject bad submissions with server-side rules — required fields, formats, and limits — in a few clicks, no backend code. fetch() callers get per-field errors.
How to Handle Form Submissions in Next.js
Three ways to handle a Next.js form — Server Actions, Route Handlers, or a form backend — with code for each and a clear rule for choosing.
What Is a Form Backend? (And When You Need One)
The plain-language definition, how form endpoints work, and when a hosted backend beats rolling your own.