Tutorials

HTML contact form that sends email: a complete guide

December 16, 202511 min readUpdated October 25, 2026
HTML contact form that sends email

If you've ever written a contact form in HTML and expected it to send an email, you've probably run into a frustrating discovery: it doesn't, not on its own.

HTML forms are just a way to package data and send it somewhere. The "sending email" part is always done by a server receiving that data and deciding what to do with it. That server might be PHP code you wrote, a Node.js function, or a hosted service, but something has to be on the other end.

This guide covers the whole picture: how the form's action attribute works, what your options are for the "server" part, how to submit the form with JavaScript, and how to build an email form for your website that reliably delivers to your inbox.

The action attribute: how HTML forms send data

When a user fills out a form and clicks submit, the browser collects all the field values and sends them to whichever URL is in the action attribute, using the HTTP method specified by method.

<form action="https://example.com/contact" method="POST">
  <input name="email" type="email" />
  <button type="submit">Send</button>
</form>

That's it. The browser does a POST request to https://example.com/contact with email=user@example.com in the body. What happens next is entirely up to whatever is running at that URL.

If nothing is running at that URL, you get a 404. If what's running there doesn't send an email, no email gets sent. The HTML itself has no opinion about email.

The mailto: trap

You might have seen this:

<form action="mailto:you@yoursite.com" method="POST"></form>

This opens the visitor's desktop email client with a draft pre-filled with the form data. It has a few problems:

  • Most people browsing your site aren't logged into a desktop email client
  • On mobile, it opens the mail app, often empty or broken
  • You have no record of what was submitted
  • It looks like a 1998 geocities site

Don't use mailto: for contact forms. It's not a solution; it's an accidental footgun that's survived for decades.

What you actually need

Something has to receive your form submission and send the email. Your options:

ApproachYou maintainWorks on static sitesSetup time
PHP mail()A PHP serverNoHours (and headaches)
Custom Node.js APIA server + email API keyNoHours
Serverless functionA function + email API keyYes30–60 min
Form backend serviceNothingYes2 min

If you want the full picture of what a custom endpoint actually involves before committing to that path, The hidden complexity of handling form submissions maps out what you're signing up for. If you already run a Node.js server and want to send the email yourself, How to build a Node.js contact form that sends email walks through it with Express and Nodemailer.

For most projects, including static sites, HTML pages, and simple websites, a form backend service is the right call. You point your form's action at a hosted URL, and submissions are stored and emailed to you. No server to maintain, no email API to configure.

The rest of this guide uses Formtorch as the form backend, but the HTML patterns work with any similar service.

Building the form

Here's a complete, accessible HTML contact form:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Contact</title>
  </head>
  <body>
    <form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
      <div>
        <label for="name">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          placeholder="Jane Smith"
          required
          autocomplete="name"
        />
      </div>

      <div>
        <label for="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          placeholder="jane@example.com"
          required
          autocomplete="email"
        />
      </div>

      <div>
        <label for="subject">Subject</label>
        <input
          id="subject"
          name="subject"
          type="text"
          placeholder="What's this about?"
        />
      </div>

      <div>
        <label for="message">Message</label>
        <textarea
          id="message"
          name="message"
          rows="6"
          placeholder="Your message…"
          required
        ></textarea>
      </div>

      <button type="submit">Send message</button>
    </form>
  </body>
</html>

A few things to notice:

label elements with matching for/id pairs: screen readers use these to describe inputs. They also make the label clickable, which expands the tap target on mobile. Always write them.

type="email" on the email field: the browser validates the format before submitting and shows an email-friendly keyboard (with @ close at hand) on mobile.

required attributes: the browser blocks submission if these fields are empty. It's not a substitute for server-side validation, but it catches honest mistakes immediately.

autocomplete hints: these tell the browser (and password managers) what kind of data goes in each field, enabling autofill. autocomplete="name" and autocomplete="email" are the ones that matter here.

Getting your form endpoint

You need a Formtorch account and a form created in the dashboard.

Create an account

Sign up at formtorch.com. The free plan includes 150 submissions per month, which is plenty for a contact form.

Create a project and form

Click New Project, then New Form. Give the form a name, like "Contact Form". Your endpoint URL is generated immediately.

Copy the endpoint URL

Your endpoint looks like https://formtorch.com/f/abc123. Replace YOUR_FORM_ID in the form above with the ID from that URL.

Check where notifications go

New projects send notifications to your account email by default, so every submission already emails you. To use a different address, add it under Project Settings → Notifications and confirm it from the verification email.

Redirecting after submission

By default, Formtorch shows a generic success page after submission. You'll almost certainly want to send people to your own page:

<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
  <!-- Hidden field: controls where users land after submitting -->
  <input
    type="hidden"
    name="_redirect"
    value="https://yoursite.com/contact/thank-you"
  />

  <!-- your visible fields -->
  <input name="name" type="text" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send message</button>
</form>
Note

Fields with names starting with _ are metadata that control Formtorch's behavior. They're stripped from the submission before it's stored or emailed to you.

Your thank-you page can be as simple as a static HTML file with a "Thanks, we'll be in touch." message. It doesn't need to do anything except exist and feel like a confirmation.

Sending the form with JavaScript (fetch)

A lot of people arrive at this problem from the other direction: "Can't I send the email with JavaScript instead?"

Not from the browser. Sending email means talking to a mail server over SMTP, and browsers have no way to open that kind of connection. Any library that claims to send email from front-end JavaScript is making an HTTP request to a server that sends the email for you.

What JavaScript can do is change how the form submits. Instead of the browser navigating to the action URL, a script sends the same data with fetch() and keeps the visitor on the page. It helps to keep the two halves separate:

  • Browser side: collect the fields and send them to an endpoint. This is all fetch() does.
  • Server side: receive the data, store it, and send the email. This happens on whatever is at the endpoint, whether that's your own server or a form backend.

Here's the form from earlier, enhanced with a script. The action and method stay in place, so the form still works if JavaScript fails to load:

<form
  id="contact-form"
  action="https://formtorch.com/f/YOUR_FORM_ID"
  method="POST"
>
  <!-- the same name, email, and message fields as above -->
  <button type="submit">Send message</button>
  <p id="form-status" role="status"></p>
</form>

<script>
  const form = document.getElementById("contact-form");
  const statusEl = document.getElementById("form-status");
  const button = form.querySelector('button[type="submit"]');

  form.addEventListener("submit", async (event) => {
    event.preventDefault(); // stay on the page instead of navigating
    button.disabled = true;
    statusEl.textContent = "Sending…";

    try {
      const res = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" }, // ask for JSON, not a redirect
        body: new FormData(form),
      });
      const result = await res.json().catch(() => ({}));

      if (res.ok) {
        form.reset();
        statusEl.textContent = "Thanks! Your message was sent.";
      } else {
        // Details for you in the console, a plain message for the visitor
        console.error(
          `Form error ${res.status}`,
          result.errorCode,
          result.message
        );
        statusEl.textContent =
          res.status === 429
            ? "Too many attempts. Please wait a minute and try again."
            : "We couldn't send your message. Please try again.";
      }
    } catch {
      // fetch() only rejects when the request never completed
      statusEl.textContent =
        "Network error. Check your connection and try again.";
    } finally {
      button.disabled = false;
    }
  });
</script>

A few details are doing more work than they appear to:

The Accept: application/json header. Without it, Formtorch treats the request like a regular form post and responds with a redirect. With it, you get a JSON body: { "success": true, ... } on success, or { "success": false, "errorCode": "...", "message": "..." } with a 4xx status when something is wrong.

Checking res.ok. fetch() doesn't throw on a 400 or 500 response. It only rejects when the network request itself fails, such as when the visitor is offline. A script that only has a catch block will cheerfully report success for a rejected submission.

Two kinds of error messages. The errorCode and message are written for developers, so they go to the console. The visitor gets a short message they can act on. A 429 means the endpoint's rate limit kicked in, which is worth telling them, since waiting fixes it.

role="status" and the disabled button. Screen readers announce changes to a status region, so "Sending…" and the result are read out without moving focus. Disabling the button stops a double-click from submitting twice.

FormData without a Content-Type header. The browser sets the right encoding itself. You don't need to convert the fields to JSON.

One thing a successful response doesn't tell you is that the email has already arrived. A 200 means the endpoint received and stored the submission. The notification email is sent by the server afterwards, and submissions flagged as spam get the same success response without triggering an email, so bots learn nothing from the reply. If a message seems to be missing, check the dashboard before assuming the form is broken.

If you want a fuller version of this pattern, with the form swapping to a success message and a static-site angle, How to add a contact form to a static website builds one step by step.

Form HTML best practices

A few more things that are easy to skip but worth doing:

Use <label> elements, not placeholder as labels. Placeholder text disappears when you start typing. If you forget what a field is for halfway through, you have to clear it to find out. Labels stay visible.

Group related fields with <fieldset> and <legend>. Not always necessary, but for forms with multiple sections (shipping vs. billing, for example), it helps screen readers group the context.

Don't disable the submit button after one click without visual feedback. If the network is slow, users will click again, assuming it didn't register. Either disable with a spinner or change the button text to "Sending…".

Put novalidate on the form if you're handling validation yourself in JavaScript. Without it, the browser's built-in validation and your JS validation will conflict.

For a validation architecture that scales across multiple forms with consistent error shapes and shared schemas, see How to design a scalable form validation system.

What about spam?

Every publicly accessible contact form gets hit by spam. How quickly depends on how prominent your site is, but it happens to everyone eventually.

Formtorch runs spam detection automatically on every submission. Most spam never reaches your inbox. If you're building a form with custom backend code, read How to prevent spam in contact forms for practical techniques.

Testing your form

Before shipping, test the full path:

  • Fill in all required fields and submit. Do you land on your thank-you page?
  • Check your Formtorch dashboard. Does the submission appear?
  • Check your email. Did the notification arrive?
  • Try submitting with required fields empty. Does the browser block it?
  • Try submitting with an invalid email. Does the browser catch it?

The dashboard test is worth doing separately from the email check. Sometimes notification emails end up in spam, and seeing the submission in the dashboard confirms the form is working even if the email got filtered.

The full picture

HTML forms are just a delivery mechanism. The action attribute is where you point them, and that URL is what actually handles submissions, stores them, and sends email.

The simplest path to a working contact form that sends email:

  1. Write a proper HTML form with labels, input types, and required attributes
  2. Set action to your Formtorch endpoint URL
  3. Add a _redirect field pointing to your thank-you page
  4. Confirm where notification emails go in your project settings

That's a fully functional contact form. No server, no email API, no backend maintenance. If you want the visitor to stay on the page, add the fetch() script on top; the form itself doesn't change.

Ready to set up your form?

Create a free Formtorch account. Your endpoint URL is ready in under two minutes.

Related posts
Form submission webhooks: payloads, signatures, and retries
TutorialsOctober 29, 202610 min read
How to send HTML form submissions to Google Sheets
TutorialsOctober 27, 20269 min read