All resources
Static site forms

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.

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

html
<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 }.

Keep the action attribute:

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.

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

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

ResultStatusBody
Success200{ "ok": true }
Validation failed422{ "ok": false, "error": "validation", "fields": { … } }
Other rejection4xx{ "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.

Sending JSON instead of FormData:

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 _replyto to set the reply-to on the notification email, _subject to set its subject, and add a hidden, empty _gotcha honeypot 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. FormData only includes inputs that have a name attribute. A field with no name is never sent.
Prefer a typed helper?:

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

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