splitforms.com
All articles/ TUTORIALS12 MIN READPublished June 18, 2026

How to Send Form Data to Email Using JavaScript Fetch (2026)

Learn how to send form data to email with JavaScript fetch and FormData — no page reload, no backend, no SMTP. Complete working examples with splitforms.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Why fetch() instead of a traditional form submit

A traditional HTML form submit navigates the browser to the server's response URL. If the form's actionpoints to an external endpoint, the user leaves your page entirely. You can't show a success message, you can't handle errors gracefully, and the user experience feels abrupt — especially on a single-page app or a marketing site where you want to keep the visitor engaged.

fetch() changes the equation. It sends the form data as an asynchronous HTTP request from JavaScript, so the page never reloads. That gives you five things the traditional submit cannot:

  • No page reload. The user stays on the same page. You control what happens after submission entirely from JavaScript.
  • Inline success and error states. Show a green confirmation banner, a spinner while the request is in flight, and a red error message if something fails — all without redirecting.
  • Cross-origin endpoints. POST form data to any URL — splitforms, your own API, a Zapier webhook — without leaving your domain. CORS permitting, the browser handles it.
  • Custom headers. Attach authorization tokens, API keys, or custom metadata to the request. Traditional form submits have no header control.
  • JSON responses. Parse the server's reply as structured data instead of rendering an HTML page. Check status codes, read error messages, and decide what to show the user.

For modern websites — static sites, SPAs, landing pages, and any site where the form lives on the frontend — fetch() is the right tool. The traditional action attribute still works for server-rendered apps (Next.js server actions, PHP, Rails), but if you want the best UX with zero backend code, fetch is the way.

The 3 methods compared

Before diving into the implementation, here's how the three form submission approaches stack up against each other:

FeatureTraditional submitXMLHttpRequestfetch()
Page reloadYes — browser navigatesNoNo
Async handlingNoneCallback-basedPromise-based
Cross-originNo CORS restrictionCORS appliesCORS applies
Custom headersNoYesYes
Response parsingRenders HTMLManual.json(), .text()
Error handlingNone on clientonerror callbacktry/catch + response.ok
Browser supportAll browsers everAll browsersAll modern browsers
Code clarityHTML onlyVerboseClean, readable
File uploadsAutomaticManual FormDataAutomatic with FormData

fetch() wins for any site where you control the frontend. XMLHttpRequest is effectively legacy — it works, but the callback-based API is harder to read and maintain. The traditional submit still has its place in server-rendered workflows, but for email delivery through a form backend, fetch is the clear choice.

For a deeper look at the traditional approach, see the HTML form action complete guide. For the fetch-specific CORS gotcha, see CORS error form submission fix.

Step 1 — Build the HTML form

Start with a clean contact form. Three fields: name, email, and message. Each input has a name attribute — this is critical because FormData uses the name attribute as the key for each field value. Skip the name attribute and the field gets silently dropped.

<form id="contact-form">
  <label>
    Name
    <input type="text" name="name" required />
  </label>

  <label>
    Email
    <input type="email" name="email" required />
  </label>

  <label>
    Message
    <textarea name="message" rows="5" required></textarea>
  </label>

  <button type="submit">Send message</button>
</form>

A few things to notice: there's no action attribute and no method attribute on the form. We're not using the browser's built-in submission — JavaScript will handle everything. The requiredattribute on each field gives us free client-side validation before the fetch call even fires. The browser won't let the form submit (and our handler won't run) if any required field is empty or the email field fails the built-in email format check.

If you want stricter validation before the fetch — minimum length, regex patterns, confirm-email matching — add it in JavaScript before calling fetch. See how to validate HTML forms with JavaScript for patterns.

Step 2 — Intercept the submit event with JavaScript

The core pattern is an event listener on the form's submit event. Inside the handler, the first thing you do is call e.preventDefault() — this stops the browser from doing its default form submission (the page reload). Without preventDefault(), the browser navigates away and your fetch call never runs.

const form = document.getElementById("contact-form");

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  // Your fetch logic goes here
});

preventDefault()is the single most important line in this whole pattern. Forget it and you'll spend ten minutes wondering why the page keeps reloading and the fetch call never fires. It's the number one mistake developers make when switching from a traditional form submit to a JavaScript-handled submit.

The handler is async so we can use await inside it. This makes the code read top-to-bottom instead of nesting .then()callbacks. If you need to support very old browsers (IE 11), you'd use XMLHttpRequest or a polyfill — but for any modern site, async/await with fetch is the standard.

Step 3 — Collect form data with FormData

new FormData(form)inspects the form element, finds every named input, textarea, and select, and collects their current values into a key-value map. You don't manually read each field — FormData does it for you.

const form = document.getElementById("contact-form");

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const formData = new FormData(form);

  // formData contains:
  // name    → "Jane Doe"
  // email   → "jane@example.com"
  // message → "Hello, I have a question..."
});

FormData automatically picks up the current value of every named field at the moment you create it. If the user typed into the name field, formData.get("name") returns what they typed. If a checkbox is checked, its value is included; if unchecked, it's excluded. File inputs are captured as File objects with their binary data intact.

You can also manually append fields that don't have a corresponding input: formData.append("source", "contact-page"). This is useful for tracking which page the user submitted from, or adding metadata like an access key for your form backend.

Step 4 — POST with fetch()

Pass the FormData directly as the bodyof a POST request. That's the entire client-side submission — four lines of code after FormData:

const form = document.getElementById("contact-form");

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const formData = new FormData(form);

  const response = await fetch("https://splitforms.com/api/submit", {
    method: "POST",
    body: formData,
  });
});

The critical detail: do not set Content-Type. When you pass a FormData object as the body, the browser automatically sets the Content-Type header to multipart/form-data with a unique boundary string that separates each field in the request body. If you manually set Content-Type: multipart/form-data, you overwrite the boundary and the server cannot parse the body. This is the second most common mistake after forgetting preventDefault().

For plain text forms (no file uploads), you could also send as URL-encoded form data by calling formData.toString()— but there's no benefit. Let the browser use multipart. It works identically for text fields and files, and the overhead is negligible.

Step 5 — Handle success and error states

After the fetch call, check response.ok (a boolean that's truefor any 2xx status). If it's true, show a success message and reset the form. If it's false, read the error from the response body and show it. Wrap everything in try/catch for network-level failures.

const form   = document.getElementById("contact-form");
const btn    = form.querySelector("button[type='submit']");
const result = document.getElementById("form-result");

form.addEventListener("submit", async (e) => {
  e.preventDefault();

  const originalText = btn.textContent;
  btn.disabled  = true;
  btn.textContent = "Sending...";
  result.textContent = "";

  try {
    const formData = new FormData(form);

    const response = await fetch("https://splitforms.com/api/submit", {
      method: "POST",
      body: formData,
    });

    if (response.ok) {
      result.textContent = "Message sent successfully.";
      result.style.color = "#16a34a";
      form.reset();
    } else {
      const error = await response.text();
      result.textContent = "Something went wrong. Please try again.";
      result.style.color = "#dc2626";
      console.error("Form error:", response.status, error);
    }
  } catch (err) {
    result.textContent = "Network error. Please check your connection.";
    result.style.color = "#dc2626";
    console.error("Fetch error:", err);
  } finally {
    btn.disabled = false;
    btn.textContent = originalText;
  }
});

This is the complete working pattern. Here's what each piece does:

  • btn.disabled = true — prevents double-submission while the request is in flight. The user can't click the button again until the request finishes.
  • btn.textContent = "Sending..." — gives the user immediate feedback that their click registered.
  • try/catch — the try block handles HTTP-level errors (4xx, 5xx) via response.ok. The catch block handles network failures (no internet, DNS error, CORS blocked).
  • form.reset() — clears all fields after a successful submission so the user can send another message or see a clean form.
  • finally — always re-enables the button and restores the original text, whether the request succeeded or failed.

For a more polished UX you can replace the text-based loading state with a CSS spinner — add a spinner class, toggle it with btn.classList.add("loading"), and remove it in finally. The logic is identical.

Step 6 — Advanced: send as JSON

Some APIs expect JSON instead of multipart form data. To convert a FormData object to JSON, use Object.fromEntries(formData) and then JSON.stringify() the result. When you send JSON, you do need to set the Content-Type header manually.

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const formData = new FormData(form);

  const data = Object.fromEntries(formData);
  // data = { name: "Jane", email: "jane@example.com", message: "..." }

  const response = await fetch("https://your-api.com/contact", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
});

The Object.fromEntries() call converts the FormData's key-value pairs into a plain JavaScript object. Each named field becomes a property. JSON.stringify() serializes it into a JSON string that the server can parse with its standard JSON body parser.

When to use JSON vs multipart:

  • JSON — when the endpoint is your own API, a third-party REST API, or any service that expects application/json. Better for structured data, smaller payload size for text-only forms, and easier server-side parsing.
  • Multipart (FormData) — when the endpoint expects form data, when you need file uploads, or when you're posting to a generic form backend like splitforms. Files cannot be sent as JSON without base64 encoding, which inflates size by 33%.

One caveat: Object.fromEntries(formData) drops duplicate keys. If your form has multiple checkboxes with the same name attribute, only the last value survives. For arrays, use formData.getAll("tags") and build the object manually.

Sending to splitforms specifically

splitforms is a form backend that receives your POST, processes the data, and delivers it to your email. You don't run any server code — just point your fetch at the splitforms endpoint with your access key.

The endpoint is https://splitforms.com/api/submit. Include your access key as a hidden field in the form or append it to the FormData before sending:

<form id="contact-form">
  <input type="hidden" name="access_key" value="pk_your_access_key_here" />

  <label>
    Name
    <input type="text" name="name" required />
  </label>

  <label>
    Email
    <input type="email" name="email" required />
  </label>

  <label>
    Message
    <textarea name="message" rows="5" required></textarea>
  </label>

  <button type="submit">Send message</button>
</form>

<script>
const form = document.getElementById("contact-form");
const btn  = form.querySelector("button[type='submit']");
const msg  = document.getElementById("form-result");

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  btn.disabled = true;
  btn.textContent = "Sending...";

  try {
    const res = await fetch("https://splitforms.com/api/submit", {
      method: "POST",
      body: new FormData(form),
    });

    if (res.ok) {
      msg.textContent = "Sent! We'll reply shortly.";
      msg.style.color = "#16a34a";
      form.reset();
    } else {
      msg.textContent = "Failed to send. Please try again.";
      msg.style.color = "#dc2626";
    }
  } catch (err) {
    msg.textContent = "Network error. Check your connection.";
    msg.style.color = "#dc2626";
  } finally {
    btn.disabled = false;
    btn.textContent = "Send message";
  }
});
</script>

To verify it works: open the page, fill out the form, and submit. Check the email address configured in your splitforms dashboard. If the email arrives, everything is wired correctly. If it doesn't, check the browser console for CORS errors and verify your access key is correct.

Get a free access key at splitforms.com/login — no credit card required, 500 submissions per month on the free tier. The dashboard lets you configure delivery email, spam filtering, webhook forwarding, and notification preferences.

Common mistakes

Five mistakes that account for the vast majority of form-submission bugs:

Forgetting preventDefault()

The form's submit handler fires, but before your fetch call runs, the browser navigates to the form's action URL (or reloads the current page). Your fetch may or may not fire in the background, but you never see the result because the page is gone. Fix: e.preventDefault() is always the first line inside your submit handler.

Setting Content-Type manually with FormData

You set headers: { "Content-Type": "multipart/form-data" }thinking you're being explicit. But this overwrites the boundary token that the browser generates. The server receives a multipart body with no boundary and rejects it with a 400 error. Fix: don't set Content-Type at all when using FormData. The browser handles it.

Not handling network errors

You call await fetch(url)without try/catch. If the user's internet drops, DNS fails, or the request times out, the promise rejects and the error goes to the unhandled promise rejection handler. The user sees nothing — the button just stays in its loading state forever. Fix: always wrap fetch in try/catch and show an error message in the catch block.

CORS issues (fetch vs traditional submit)

A traditional form submit to an external URL works fine — the browser doesn't apply CORS to navigation requests. But when you switch to fetch(), the browser applies CORS policy, and if the target server doesn't respond with Access-Control-Allow-Origin, the request is blocked. This is the most confusing difference for developers migrating from traditional submits to fetch. Fix: use a backend that handles CORS for browser requests (splitforms does this automatically), or proxy through your own server. See CORS error form submission fix for the full explanation.

Not checking response.ok for HTTP errors

fetch() only rejects on network failures. A 422 validation error, a 401 unauthorized, or a 500 server error does not throw — it returns a Response object with ok: false. If you only have a catch block and no if (!response.ok) check, server-side errors silently pass through. The user sees "Sent!" even though nothing was delivered. Fix: always check response.ok and handle non-2xx status codes explicitly.

Complete working example (steps 1–5 combined)

Here's the entire thing in one file — HTML form, CSS for states, and JavaScript with fetch, FormData, loading state, success, and error handling. Copy it into an .html file, replace the access key, and it works:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Contact Form</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 520px; margin: 40px auto; padding: 0 20px; }
    label { display: block; margin-bottom: 16px; font-weight: 600; font-size: 14px; color: #333; }
    input, textarea { display: block; width: 100%; padding: 10px 12px; margin-top: 4px;
                      border: 1px solid #ccc; border-radius: 8px; font-size: 15px; box-sizing: border-box; }
    textarea { resize: vertical; }
    button { padding: 12px 24px; background: #111; color: #fff; border: none;
            border-radius: 8px; font-size: 15px; cursor: pointer; }
    button:disabled { opacity: 0.5; cursor: not-allowed; }
    #form-result { margin-top: 16px; font-size: 14px; min-height: 20px; }
  </style>
</head>
<body>
  <form id="contact-form">
    <input type="hidden" name="access_key" value="pk_your_access_key_here" />

    <label>Name
      <input type="text" name="name" required />
    </label>

    <label>Email
      <input type="email" name="email" required />
    </label>

    <label>Message
      <textarea name="message" rows="5" required></textarea>
    </label>

    <button type="submit">Send message</button>
  </form>

  <div id="form-result"></div>

  <script>
    const form = document.getElementById("contact-form");
    const btn  = form.querySelector("button[type='submit']");
    const msg  = document.getElementById("form-result");

    form.addEventListener("submit", async (e) => {
      e.preventDefault();

      const originalText = btn.textContent;
      btn.disabled  = true;
      btn.textContent = "Sending...";
      msg.textContent = "";

      try {
        const res = await fetch("https://splitforms.com/api/submit", {
          method: "POST",
          body: new FormData(form),
        });

        if (res.ok) {
          msg.textContent = "Message sent. We'll get back to you shortly.";
          msg.style.color = "#16a34a";
          form.reset();
        } else {
          const body = await res.text();
          msg.textContent = "Something went wrong. Please try again.";
          msg.style.color = "#dc2626";
          console.error("Status:", res.status, "Body:", body);
        }
      } catch (err) {
        msg.textContent = "Network error. Check your connection and try again.";
        msg.style.color = "#dc2626";
        console.error("Fetch failed:", err);
      } finally {
        btn.disabled = false;
        btn.textContent = originalText;
      }
    });
  </script>
</body>
</html>

That's the complete pattern. One HTML file, no build step, no framework, no backend. Swap in your access key and it delivers form submissions to email through splitforms.

Next steps

Get started with splitforms

Stop wiring SMTP, writing PHP handlers, or debugging Node mailers.

Point your fetch() call at splitforms and every form submission lands in your dashboard on Free. Spam filtering and dashboard capture are included; Starter adds inbox delivery, webhook forwarding, exports, and retained uploads. No credit card, no server, no config.

Get your free access key

FAQ

Do I need to set Content-Type when using FormData with fetch?

No. When you pass a FormData object as the body, the browser automatically sets Content-Type to multipart/form-data with the correct boundary token. If you set it manually, you overwrite the boundary and the server cannot parse the body. Leave it out entirely.

Why does fetch throw a CORS error but a normal form submit doesn't?

Traditional form submits are classified as 'simple requests' and are exempt from CORS policy by the spec. fetch(), however, is always subject to CORS. If your form backend is on a different origin, that backend must respond with the correct Access-Control-Allow-Origin headers. splitforms handles this automatically for browser-to-API requests.

Can I send form data as JSON instead of FormData?

Yes. Convert the FormData to a plain object with Object.fromEntries(formData), then JSON.stringify it and set Content-Type: application/json. This works for text fields but does not work for file uploads — binary file data does not survive JSON serialization without base64 encoding.

How do I handle file uploads with fetch?

FormData handles files automatically. Include an <input type='file'> in your form, pass the form element to new FormData(form), and POST it with fetch. The browser packages the file bytes into the multipart body. No special handling needed on the client side. See the splitforms endpoint docs for server-side file limits.

What happens if the network request fails?

fetch() rejects its promise on network-level failures (DNS failure, no internet, CORS error). Wrap the call in try/catch, catch the error, and show a user-friendly message. For HTTP errors (4xx, 5xx), fetch does not reject — check response.ok or response.status inside the try block. Optionally retry once after a short delay.

How do I show a loading state while the form is submitting?

Disable the submit button, change its text to a loading indicator, and re-enable it in a finally block. The pattern is: set a loading flag to true before await fetch(), then set it to false in both the then and catch paths (or use finally). This prevents double-submission and gives the user visual feedback that something is happening.

Do I still need server-side validation?

Yes, always. Client-side validation is for UX — it gives instant feedback and reduces unnecessary requests. Server-side validation is for security — a malicious user can bypass any client-side check by modifying the request in DevTools or sending a raw HTTP request. Validate required fields, email format, field lengths, and content on the server every time.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

500 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your dashboard. Starter adds inbox delivery.

Generate access key →Read the docs
founders pricing locked in · early access open