Vanilla JavaScript contact form, no build step
A vanilla JavaScript contact form starts before any JavaScript exists. Write the markup and it already sends:
<form action="https://api.formroute.dev/f/site_xyz" method="POST" id="contact">
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button>Send</button>
<p role="alert"></p>
</form>
The browser serialises the named fields and posts them. No framework, no bundler, no npm install. Everything after this point is optional polish on something that already works.
Enhance the form, do not replace it
The common mistake is deleting action and method and rebuilding the submission in JavaScript. That trades a form that always works for a form that works when a script loads.
Keep the attributes, then intercept:
<script>
const form = document.getElementById("contact");
const button = form.querySelector("button");
const alertBox = form.querySelector("[role=alert]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
alertBox.textContent = "";
button.disabled = true;
button.textContent = "Sending…";
try {
const response = await fetch(form.action, {
method: form.method,
body: new FormData(form),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
form.replaceWith(Object.assign(document.createElement("p"), {
textContent: "Thanks, we got it.",
}));
} catch (error) {
button.disabled = false;
button.textContent = "Send";
alertBox.textContent = `Something went wrong (${error.message}). Try again.`;
}
});
</script>
About twenty-five lines of behaviour. Three details in there are deliberate:
form.actionandform.methodare read back off the element, so the endpoint is written once, in the markup, where you will look for it later.- The alert is a single element that gets its
textContentreset on every attempt. Appending a new<p role="alert">per failure means three retries announce three messages to a screen reader. - The thrown error carries the status, so the message the visitor sees can distinguish a rejected payload from a network failure.
If the script fails to parse, if a CSP blocks it, or if the visitor has JavaScript off, the form still posts natively and the message still reaches you. That is the whole argument for this order of operations.
FormData is the entire serialisation layer
new FormData(form) collects every field with a name attribute, including checkboxes, selects and file inputs, and produces exactly what the browser would have sent on its own. There is nothing to write and nothing to install.
Two details cost people an afternoon each:
- Keys come from
name, notid. A field without anameis silently skipped. It is the most common reason a submission arrives with a field missing. - Do not set
Content-Typeyourself.fetchsetsmultipart/form-datawith the correct boundary when the body is aFormData. Setting the header by hand overwrites the boundary and the request arrives unparseable.
A honeypot costs one input
This removes a meaningful share of automated traffic for the price of a hidden field:
<div aria-hidden="true" style="position:absolute;left:-9999px">
<label>Leave this empty <input name="website" tabindex="-1" autocomplete="off" /></label>
</div>
A naive bot fills every input it finds. A human never sees this one. Move it off-screen rather than using display:none, since cruder scrapers skip hidden fields, and keep it out of the tab order and off autocomplete so nobody real lands in it.
The field name is yours to choose, and the endpoint has to be told which one it is. A honeypot only works if something rejects the submission when it arrives filled, so check how your form backend configures that rather than assuming a magic field name. It also only catches the low end: a headless browser driving your form properly walks straight past it, which is why it is a first layer and not the only one.
required and type="email" are courtesies to the honest visitor and vanish for anything that is not your page, so the rules have to hold at the endpoint instead. What a form backend does with them covers that half.
Where this version fits
Reach for this on a landing page, a static site with no framework, or anywhere adding a build step to render three inputs is the larger change. If your page is already running a framework, the equivalent is shorter: the React contact form, Vue contact form and Svelte contact form all wrap the same FormData call in their own syntax.
All four versions point at the same endpoint, which is the only part FormRoute cares about. Signups are invite-only for now: request one.