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.
<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:
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 aname, just as the browser would. Fields without anameare skipped.fetch(form.action, …)reuses the URL from the HTML, so the endpoint lives in one place.Accept: application/jsontells 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:
// 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:
{ "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:
{ "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:
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();| FormData | JSON | |
|---|---|---|
| Reads fields from a form | Automatically | You build the object |
| File uploads | Yes | No |
Content-Type header | Don't set it (the browser adds the multipart boundary) | application/json |
| Nested data | Flat key/value pairs | Objects 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:
- The browser blocks submission because a
requiredortype="email"field is invalid. Thesubmitevent doesn't fire, so no code is needed. The browser shows the message. - The server rejects it. The response has
ok: false. Showerror.message, or a custom message for codes you expect. - The request never completes, for example when the visitor is offline.
fetchthrows, so catch it.
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:
<input type="file" name="attachment" accept="image/*,.pdf" multiple>Requests can carry about 4.5 MB in total. To check before sending:
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.
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/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:
$("#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
_tsfields. Add a captcha if the form is heavily targeted. - Set a custom success message in the form's settings so
json.messagereads 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.