All resources
Static site forms

How to Submit a Form with JavaScript fetch() (and Send It to Your Email)

Send form data to your email with JavaScript fetch() and no page reload: intercept submit, POST to an endpoint that emails you, and show success or per-field errors inline.

The ShipMyForm team

· 4 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, and JavaScript cannot send email itself without exposing credentials in the page. This guide uses ShipMyForm as that endpoint: it emails you each submission server-side, so there's no server for you to write. The pattern — intercept the submit, POST the data, handle the response — is the same wherever you send it. For every other way of getting an HTML form to your email, including the no-JavaScript version, start there.

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. If you are already staring at one, fixing a CORS error on a form submission walks through what the message actually means.
  • Don't forget name. FormData only includes inputs that have a name attribute. A field with no name is never sent, which is the single most common reason a form submits and arrives empty — paste your markup into the HTML form checker if that is what you are seeing.
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