Formclick

How to Submit HTML Forms Using the Fetch API

Submit HTML forms in the background with fetch() — FormData vs JSON, loading states, success and error messages, timeouts and accessibility, in clean vanilla JavaScript.

FFormclick Team3 min read
Open laptop with visible code on screen on a wooden desk in a modern, cozy workspace.
Photo by Daniil Komov on Pexels
On this page
  1. Start with a form that works without JavaScript
  2. The minimal version
  3. FormData or JSON?
  4. The production-ready version
  5. Styling the status message
  6. Prefer not to write it yourself?
  7. Common mistakes

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 a name — including checkboxes, selects and files — exactly as the browser would.
  • Don't set Content-Type yourself when sending FormData. The browser sets it, including the multipart boundary. Setting it manually breaks the request.
  • Accept: application/json asks 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 — fetch only rejects on network failure, so we check response.ok ourselves.
  • Timeouts — AbortSignal.timeout() stops a request hanging forever.
  • Accessibility — the status paragraph has role="status" and aria-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-data manually with FormData.
  • Assuming fetch throws 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.

Found this useful? Share it.

All articles