Guides

How to add a contact form to your site — so messages actually arrive

Illustration: a filled-in envelope form flying from a website screen into the owner's mailbox

The most common way to "add a form" to a first website is a mailto: button. And that's a form that silently loses most messages: mailto: doesn't send an email — it opens the mail app on the visitor's device. Half of all people never set one up: a window flashes, they close it, the message dies.

A real form works differently: the data leaves your page for a place where you'll definitely see it — your inbox or a table. Let's build one in six steps. No server, no backend required.

Step 0. Decide where messages land

One choice before any code. Form submissions have to arrive somewhere, and a beginner has two good addresses:

  • A form service (Formspree, Web3Forms and the like). It gives you a ready-made URL: the form posts data to it, the service forwards it to your email. The fastest path — we'll use this one.
  • Your own table in a database. Messages land in your database, and you read them in a dashboard. A bit more setup, but fully yours — a great second step.

Step 1. Markup: three fields and a button

<form id="contact-form">
  <input name="email" type="email" placeholder="Your email" required />
  <textarea name="message" placeholder="Message" required></textarea>
  <input name="company" style="display:none" tabindex="-1" autocomplete="off" />
  <button type="submit">Send</button>
</form>
<p id="form-status"></p>

Two non-obvious details. Every field has a name — those are the labels your data arrives under; without them the form sends emptiness. And the hidden company field is a spam trap — more on it in step 5.

Step 2. Sending without a page reload

const form = document.getElementById("contact-form");
const status = document.getElementById("form-status");

form.addEventListener("submit", async (e) => {
  e.preventDefault(); // don't reload the page
  status.textContent = "Sending…";
  const res = await fetch("https://formspree.io/f/YOUR_ID", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
  status.textContent = res.ok
    ? "Done! I'll reply by email."
    : "Didn't go through. Please try again.";
  if (res.ok) form.reset();
});

The key line is e.preventDefault(): without it the browser reloads the page the old-fashioned way. fetch then POSTs the form data to the URL from step 3.

Step 3. Get an address from a form service

Sign up with a form service and create a form — you'll get a URL like https://formspree.io/f/abcd1234. Put it into the fetch call instead of the placeholder. That's it: the service accepts the data and forwards it to your inbox.

If you're building the site with AI, don't assemble this by hand — ask for the finished thing: "Add a contact form to my page: email and message fields, submitted via fetch to <your URL> without a reload, with 'sending / done / error' states, styled like my site." You'll get your own version of these same steps, fitted into your page.

Step 4. Show the visitor what happened

This is half of a form's quality. After the click, a person must see three states: "sending," "done," "failed." The code in step 2 already has them — the form-status line. A form without status looks broken even when it works: the visitor clicks five more times, and you get six copies of the message.

Step 5. Arm the spam trap

Spam will come — bots find every form. The cheapest defense is a honeypot, a decoy field: that hidden company input from step 1. A human never sees it, so never fills it. A bot fills everything. So any submission with company filled in can be thrown away — form services do this for you, just enable the honeypot option in settings.

That's enough for months. If it gets rough, add a captcha later — but don't start with one: captchas scare off humans too.

What you'll get

A visitor fills in two fields and clicks the button — the page doesn't reload, "Done!" appears. An email lands in your inbox with their address and text. Spam bots fall into the trap. And when messages pile up, the same fetch can redirect them into your own table — or you can wire up sending email from your code to reply automatically.

Do I have to ask for the visitor's email?

If you want to reply — yes, make the email field required. And don't ask for more: name, phone, and "how did you hear about us" all lower the send rate. Two fields is the gold standard for a contact form.

Why don't messages arrive even though the form "works"?

Three common causes: the emails land in your Spam folder (check it), the fields have no name attributes (the data arrives empty), or the URL in fetch was copied with a typo. Check in that order.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →