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.action and form.method are 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 textContent reset 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, not id. A field without a name is silently skipped. It is the most common reason a submission arrives with a field missing.
  • Do not set Content-Type yourself. fetch sets multipart/form-data with the correct boundary when the body is a FormData. 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.

Point your Vanilla JavaScript form somewhere.

FormRoute is live in private access. Drop your email and we'll send your invite.

Free forever · 1,000 submissions/mo · unlimited forms