Skip to content
FormSubmit

JavaScript

How to submit a form with JavaScript (fetch, AJAX, React and Next.js)

Submit a form with JavaScript using fetch and FormData. JSON vs FormData, loading and error states, file uploads, React, Next.js and jQuery.

6 min readUpdated Oct 8, 2026

To submit a form with JavaScript, listen for the form's submit event, call event.preventDefault(), and send new FormData(form) with fetch. The page stays put, and you show a success or error message based on the response. Send an Accept: application/json header so the server replies with JSON instead of a redirect. This pattern, often called AJAX form submission, works in plain HTML, React, Next.js and every other framework.

Below is a complete, production-ready version, followed by JSON bodies, error handling, file uploads and framework examples. The examples post to a FormSubmit endpoint, https://formsubmit.app/f/YOUR_FORM_ID, which stores each submission and emails it to you, so you don't have to write the server side.

Form submission with JavaScript: the complete pattern

Start with a normal HTML form. It works without JavaScript, which matters later.

contact.html
<form id="contact" action="https://formsubmit.app/f/YOUR_FORM_ID" method="POST">
  <label for="name">Name</label>
  <input id="name" type="text" name="name" required>

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

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

  <!-- Honeypot: hidden from people, filled in by bots -->
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">

  <button type="submit">Send</button>
  <p class="status" role="status" aria-live="polite"></p>
</form>

Then add the script:

form.js
const form = document.querySelector("#contact");
const button = form.querySelector('button[type="submit"]');
const status = form.querySelector(".status");
const renderedAt = Date.now();

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

  const data = new FormData(form);
  data.set("_ts", String(renderedAt)); // lets FormSubmit discard impossibly fast bots

  button.disabled = true;
  status.textContent = "Sending…";

  try {
    const res = await fetch(form.action, {
      method: "POST",
      body: data,
      headers: { Accept: "application/json" },
    });
    const json = await res.json();

    if (json.ok) {
      form.reset();
      status.textContent = json.message;
    } else {
      status.textContent = json.error.message;
    }
  } catch {
    status.textContent = "Network error. Check your connection and try again.";
  } finally {
    button.disabled = false;
  }
});

What each part does:

  • event.preventDefault() stops the browser's normal submission, which would navigate away from the page.
  • new FormData(form) collects every field that has a name, just as the browser would. Fields without a name are skipped.
  • fetch(form.action, …) reuses the URL from the HTML, so the endpoint lives in one place.
  • Accept: application/json tells FormSubmit to reply with JSON. Without it, you'd get a 303 redirect meant for browsers.
  • Disabling the button prevents double submissions while the request is in flight.
  • The role="status" paragraph makes screen readers announce the result.

The form generator can export this for any set of fields:

form.js
// Works with any existing <form> — add id="contact-form" to it.
const form = document.getElementById("contact-form");
const renderedAt = Date.now();

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  data.set("_ts", String(renderedAt));

  const res = await fetch("https://formsubmit.app/f/YOUR_FORM_ID", {
    method: "POST",
    body: data, // or JSON.stringify(Object.fromEntries(data)) with Content-Type: application/json
    headers: { Accept: "application/json" },
  });
  const json = await res.json();

  if (json.ok) {
    form.reset();
    form.insertAdjacentHTML("afterend", "<p>Thanks! We'll be in touch soon.</p>");
  } else {
    alert(json.error?.message ?? "Something went wrong");
  }
});

The JSON response

With Accept: application/json, a successful submission returns:

json
{ "ok": true, "id": "r8T2kLm0Qa9zXc1V", "message": "Thanks! Your submission has been received." }

message is your form's custom success message if you've set one, so you can show it directly. Errors return ok: false with a stable code you can branch on:

json
{ "ok": false, "error": { "code": "rate_limited", "message": "Too many submissions. Please wait a minute and try again." } }

The codes include domain_not_allowed, rate_limited, captcha_failed, file_too_large and payload_too_large. The full list is in the endpoint reference.

FormSubmit returns JSON when any of these is true: the request body is JSON, the Accept header asks for application/json (without text/html), X-Requested-With: XMLHttpRequest is sent, or the form includes a _format=json field.

Sending JSON instead of FormData

If your values come from state or a form library rather than a form element, send JSON:

send-json.js
const res = await fetch("https://formsubmit.app/f/YOUR_FORM_ID", {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify({
    name: "Ada Lovelace",
    email: "ada@example.com",
    message: "Hello!",
    _subject: "Website message",
  }),
});
const json = await res.json();
FormDataJSON
Reads fields from a formAutomaticallyYou build the object
File uploadsYesNo
Content-Type headerDon't set it (the browser adds the multipart boundary)application/json
Nested dataFlat key/value pairsObjects and arrays

A common bug is setting Content-Type: multipart/form-data manually with a FormData body. That drops the boundary and the server can't parse the request. Leave the header off and let the browser set it.

You can also convert a form to an object with Object.fromEntries(new FormData(form)). Be aware that this keeps only the last value for repeated names, such as multiple checkboxes.

Handling errors and loading state

Three kinds of failure are worth handling separately:

  1. The browser blocks submission because a required or type="email" field is invalid. The submit event doesn't fire, so no code is needed. The browser shows the message.
  2. The server rejects it. The response has ok: false. Show error.message, or a custom message for codes you expect.
  3. The request never completes, for example when the visitor is offline. fetch throws, so catch it.
js
if (!json.ok && json.error.code === "rate_limited") {
  status.textContent = "You're sending messages too quickly. Please wait a minute.";
}

Note that fetch doesn't throw on HTTP errors like 400 or 429. Check res.ok or the ok field in the body.

Progressive enhancement

Keep action and method="POST" on the form even though JavaScript handles the submit. If the script fails to load, is blocked, or hits an error before attaching the listener, the form still submits as a normal HTML post and the visitor lands on a thank-you page. Nothing is lost.

If you trigger submission from code, use form.requestSubmit() rather than form.submit(). requestSubmit() runs validation and fires the submit event, so your handler runs. submit() skips both.

For how plain submission works under the hood, see HTML form action explained.

File uploads with FormData

FormData includes files automatically. Add a file input and the code above works unchanged:

html
<input type="file" name="attachment" accept="image/*,.pdf" multiple>

Requests can carry about 4.5 MB in total. To check before sending:

js
const total = [...form.querySelectorAll('input[type="file"]')]
  .flatMap((input) => [...input.files])
  .reduce((sum, file) => sum + file.size, 0);

if (total > 4.5 * 1024 * 1024) {
  status.textContent = "Attachments are too large. Please keep them under 4.5 MB in total.";
  return;
}

For larger files, upload directly from the browser to storage as described in file uploads. The free plan allows files up to 10 MB each.

React form submission

In React, the pattern is the same: an onSubmit handler, FormData and fetch, with state for the loading and result messages.

ContactForm.tsx
import { useState, type FormEvent } from "react";

const ENDPOINT = "https://formsubmit.app/f/YOUR_FORM_ID";

export default function ContactForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
  const [error, setError] = useState("");
  const [renderedAt] = useState(() => Date.now());

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");
    const data = new FormData(e.currentTarget);
    data.set("_ts", String(renderedAt)); // helps FormSubmit's time-based spam check
    try {
      const res = await fetch(ENDPOINT, {
        method: "POST",
        body: data,
        headers: { Accept: "application/json" },
      });
      const json = await res.json();
      if (!res.ok || !json.ok) throw new Error(json.error?.message ?? "Something went wrong");
      setStatus("success");
    } catch (err) {
      setError((err as Error).message);
      setStatus("error");
    }
  }

  if (status === "success") {
    return <p className="text-green-700">Thanks! We'll be in touch soon.</p>;
  }

  return (
    <form onSubmit={onSubmit} className="mx-auto max-w-lg space-y-5">
      <div className="space-y-1.5">
        <label htmlFor="fs-name" className="block text-sm font-medium text-gray-900">Name</label>
        <input id="fs-name" type="text" name="name" placeholder="Jane Doe" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
      </div>
      <div className="space-y-1.5">
        <label htmlFor="fs-email" className="block text-sm font-medium text-gray-900">Email</label>
        <input id="fs-email" type="email" name="email" placeholder="jane@example.com" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
      </div>
      <div className="space-y-1.5">
        <label htmlFor="fs-message" className="block text-sm font-medium text-gray-900">Message</label>
        <textarea id="fs-message" name="message" rows={5} placeholder="How can we help?" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"></textarea>
      </div>
      {/* Honeypot: bots fill this in, humans never see it */}
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
      {status === "error" && <p className="text-sm text-red-600">{error}</p>}
      <button type="submit" disabled={status === "sending"} className="inline-flex w-full items-center justify-center rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50">
        {status === "sending" ? "Sending…" : "Send"}
      </button>
    </form>
  );
}

Uncontrolled inputs plus new FormData(e.currentTarget) keep the component small. If you use react-hook-form or controlled inputs, send JSON from your values instead. The React contact form guide has a react-hook-form and zod version.

Next.js

In the App Router, a form component that uses state needs "use client". The browser can post straight to FormSubmit, so you don't need an API route or server action, and no secrets are involved:

app/contact/ContactForm.tsx
// app/contact/contact-form.tsx
"use client";

import { useState, type FormEvent } from "react";

const ENDPOINT = "https://formsubmit.app/f/YOUR_FORM_ID";

export default function ContactForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
  const [error, setError] = useState("");
  const [renderedAt] = useState(() => Date.now());

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");
    const data = new FormData(e.currentTarget);
    data.set("_ts", String(renderedAt)); // helps FormSubmit's time-based spam check
    try {
      const res = await fetch(ENDPOINT, {
        method: "POST",
        body: data,
        headers: { Accept: "application/json" },
      });
      const json = await res.json();
      if (!res.ok || !json.ok) throw new Error(json.error?.message ?? "Something went wrong");
      setStatus("success");
    } catch (err) {
      setError((err as Error).message);
      setStatus("error");
    }
  }

  if (status === "success") {
    return <p className="text-green-700">Thanks! We'll be in touch soon.</p>;
  }

  return (
    <form onSubmit={onSubmit} className="mx-auto max-w-lg space-y-5">
      <div className="space-y-1.5">
        <label htmlFor="fs-name" className="block text-sm font-medium text-gray-900">Name</label>
        <input id="fs-name" type="text" name="name" placeholder="Jane Doe" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
      </div>
      <div className="space-y-1.5">
        <label htmlFor="fs-email" className="block text-sm font-medium text-gray-900">Email</label>
        <input id="fs-email" type="email" name="email" placeholder="jane@example.com" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
      </div>
      <div className="space-y-1.5">
        <label htmlFor="fs-message" className="block text-sm font-medium text-gray-900">Message</label>
        <textarea id="fs-message" name="message" rows={5} placeholder="How can we help?" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"></textarea>
      </div>
      {/* Honeypot: bots fill this in, humans never see it */}
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
      {status === "error" && <p className="text-sm text-red-600">{error}</p>}
      <button type="submit" disabled={status === "sending"} className="inline-flex w-full items-center justify-center rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50">
        {status === "sending" ? "Sending…" : "Send"}
      </button>
    </form>
  );
}

Import it from any page, for example app/contact/page.tsx. More detail, including static export, is in the Next.js contact form guide.

jQuery $.ajax

On an older site that already loads jQuery, the same request looks like this:

jquery-form.js
$("#contact").on("submit", function (event) {
  event.preventDefault();
  $.ajax({
    url: this.action,
    method: "POST",
    data: new FormData(this),
    processData: false,
    contentType: false,
    dataType: "json",
  })
    .done((json) => $(this).find(".status").text(json.message))
    .fail((xhr) => $(this).find(".status").text(xhr.responseJSON?.error?.message ?? "Something went wrong."));
});

processData: false and contentType: false are needed for FormData. jQuery sends X-Requested-With: XMLHttpRequest on same-origin requests, but not cross-origin. dataType: "json" adds an Accept: application/json header, so FormSubmit replies with JSON either way. You don't need jQuery for a new project; fetch is built into every modern browser.

Before you launch

  • Add your domain to the domain allowlist so the endpoint only accepts submissions from your site.
  • Keep the honeypot and _ts fields. Add a captcha if the form is heavily targeted.
  • Set a custom success message in the form's settings so json.message reads the way you want.
  • Want the submission emailed with a particular subject? See send an HTML form to email.

Need the whole thing, not just the script? There's a ready-made contact form for website projects, and contact form without PHP covers moving off a PHP handler. FormSubmit's free plan includes one form and 50 submissions a month.

Frequently asked questions

How do I submit a form with JavaScript without reloading the page?

Listen for the form's submit event, call event.preventDefault(), then send new FormData(form) with fetch. Show a message based on the response instead of navigating.

Should I send FormData or JSON?

FormData is simplest. It reads every named field, supports files and needs no Content-Type header. Send JSON when your data comes from state or a form library rather than the form element, and you have no files.

Why does form.submit() skip my submit handler?

form.submit() submits directly without firing the submit event or running validation. Use form.requestSubmit() to behave like a click on the submit button.

How do I get a JSON response instead of a redirect?

Send an Accept application/json header. FormSubmit also returns JSON when the body is JSON, when X-Requested-With is XMLHttpRequest, or when the form includes _format=json.

Is it safe to put the form endpoint in client-side JavaScript?

Yes. The endpoint only accepts submissions and can't be used to read data. Use the domain allowlist and spam protection to limit abuse.

Keep reading

Your form backend is 60 seconds away

Sign up with Google or email, create a form, paste the endpoint. Free forever for small sites — no credit card.