You have a static page with a contact form. You want what people write in it to arrive in your inbox. You search, and half the answers say mailto:, the other half say "use a backend", and nobody tells you which details will bite you at 2am when a customer says they wrote and you never got it.
Here is the whole picture: why HTML alone can't do this, the three options that actually work, and the failure modes each one has.
Why plain HTML can't send email
A <form> element does exactly one thing: it takes the fields, encodes them, and makes an HTTP request to whatever is in action. That's it. It has no idea what email is.
Email is a different protocol — SMTP — spoken by mail servers that require authentication, and a browser cannot speak it. Something on a server has to sit between the HTTP request and the mail server. That "something" is the entire question; the HTML part is trivial:
<form action="https://example.com/handle-form" method="POST">
<label for="email">Your email</label>
<input id="email" type="email" name="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send</button>
</form>
Every option below is just a different answer to "what is at the other end of action".
mailto: is not sending email
<!-- please don't -->
<form action="mailto:you@example.com" method="POST">
This doesn't send anything. It asks the visitor's operating system to open their configured mail client with a draft. Which means:
- On a machine with no mail client configured — a lot of them, especially anything where webmail is the norm — nothing happens at all. The visitor clicks Send, sees nothing, and leaves.
- If a client does open, the visitor still has to press send themselves. Most won't.
- The encoding is ugly and inconsistent across clients; multi-field forms arrive as unreadable soup.
- Your address sits in the page source in plain text, which is exactly what address-harvesting crawlers look for.
mailto: as a plain link ("email us at…") is fine. As a form target it's a broken submit button.
Option 1: your own endpoint
You write a small server-side handler and point action at it. It validates the input and hands it to a mail provider's API (Resend, Postmark, SES, Mailgun) or to SMTP directly.
// api/contact.js — a serverless function, but a PHP/Rails/Django handler is the same shape
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end()
const { email, message } = req.body
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email ?? '')) {
return res.status(422).json({ error: 'Invalid email' })
}
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Website <forms@yourdomain.com>', // your domain — always
to: 'you@yourdomain.com',
reply_to: email, // the visitor goes here, not in `from`
subject: 'New contact form submission',
text: message,
}),
})
res.redirect(303, '/thanks')
}
Choose this when you already run a server or a serverless platform, and you want the data to stay entirely yours.
What you're taking on: deliverability (SPF, DKIM, DMARC on your domain — skip these and your mail goes to spam), spam filtering, rate limiting, and the fact that a form which silently stopped working looks exactly like a form nobody used.
Option 2: a form backend service
You point action at a service's URL. It stores the submission and emails you. No server, no build step, works from a plain .html file on any host.
<form action="https://catchform.dev/f/YOUR_TOKEN" method="POST">
Choose this when the site is static and the form is not the product. You're paying someone to own the boring parts: deliverability, spam, retries, storage.
What you're giving up: submissions live in someone else's database, and you're one more service dependency deep. Disclosure: CatchForm is mine — the concrete numbers at the end of this post are its numbers, everything above and below is not about it.
Option 3: a hosted form builder
Google Forms, Typeform and friends. You don't write the HTML at all; you embed theirs or link out.
Choose this when the form matters more than the page — long surveys, questionnaires, anything with logic and reporting. Don't choose it for a contact form: an iframe you can't style, on someone else's domain, is a bad first impression on your own site.
Whatever you picked, the following details are where these things break. They are the same for all three.
The details that bite
Never put the visitor's address in From:
This is the single most common way self-built form mail ends up in spam. It feels natural to set from: visitor@gmail.com so replies just work — but your server is not authorized to send as gmail.com. SPF and DKIM checks fail, and receiving servers either bin the message or reject it outright.
The rule: From: is always your own domain. The visitor goes in Reply-To:. You still hit Reply and answer them; the mail is authenticated properly on the way in.
A newline in a form field is a mail header
If you build headers by concatenating raw input:
Reply-To: <what the visitor typed>
…then a visitor who types me@example.com\nBcc: everyone@else.com just added a header. This is header injection, it's decades old, and it's still trivially reachable through contact forms. Validate the address (filter_var, a real email validator, anything) and never interpolate raw field values into headers.
CORS: fetch() and a native form submit are not the same request
This one produces the most confusing bug report in the category: "the form shows an error but the submissions arrive twice."
A native <form method="POST"> submit is a page navigation. CORS does not apply to it — it works across domains, always, and it always has.
fetch() is subject to CORS, and the details matter:
- If your body is
FormDataorURLSearchParams, it's a simple request — no preflight. The browser sends it, your server receives and stores it, and then the browser refuses to hand the response back to your JS because theAccess-Control-Allow-Originheader wasn't there. Your code shows "failed", the visitor submits again, you get duplicates. - If you send
Content-Type: application/json, it's a preflighted request — the browser sendsOPTIONSfirst. If the endpoint doesn't answer that, nothing is sent at all.
So: a failed cross-origin fetch does not mean the submission was lost. Check the receiving side before you retry. And if you own the endpoint, answer OPTIONS and set the headers.
form.addEventListener('submit', async (e) => {
e.preventDefault()
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' }, // ask for JSON instead of a redirect
body: new FormData(form), // let the browser set Content-Type
})
if (res.ok) showThanks()
else showError()
})
Don't set Content-Type by hand for FormData — you'll strip the multipart boundary and the server will parse nothing.
Decide what the response is before you build the form
A native form submit needs somewhere to land: a redirect to a thank-you page (303), or you leave the visitor staring at raw JSON. A fetch submit needs a status code it can branch on. Pick one per form and make the endpoint honor it — usually via the Accept header, as above.
Spam: honeypot first, CAPTCHA only if that fails
A honeypot is a field a human never sees and a bot fills in anyway:
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
aria-hidden="true" style="position:absolute;left:-9999px">
Hide it with CSS, not type="hidden" — plenty of bots skip hidden inputs. tabindex="-1" keeps keyboard users out of it, aria-hidden keeps screen readers out of it, autocomplete="off" stops the browser from helpfully filling it in for a real person.
Non-empty on arrival → drop it. This costs nothing and removes most of the volume. Reach for a CAPTCHA only when someone is targeting you specifically, because every CAPTCHA is a tax on the humans too.
File uploads need enctype
<form action="…" method="POST" enctype="multipart/form-data">
<input type="file" name="attachment">
</form>
Without enctype="multipart/form-data" the browser sends the file name and not the file. Check the size limit on both ends — the receiving side's limit and, if you're hosting the handler, the web server's own body-size cap, which is usually the smaller of the two.
A concrete endpoint, end to end
To make the above less abstract, here is exactly how the receiving side behaves in CatchForm (my service, as disclosed above) — the shape is representative of the category, the numbers are its own:
<form action="https://catchform.dev/f/YOUR_TOKEN" method="POST"
enctype="multipart/form-data">
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
aria-hidden="true" style="position:absolute;left:-9999px">
<button type="submit">Send</button>
</form>
-
The response. With no redirect configured on the form, you get
200and{"success": true}— the JSON that afetchhandler wants. Configure a redirect URL and the same endpoint answers302to it instead, which is what a native form submit wants. -
Cross-origin is the normal case. The endpoint exists to accept POSTs from domains it has never seen, so
OPTIONSis answered with204andAccess-Control-Allow-Origin: *. No cookies, no credentials — the token in the URL is the entire mandate. -
Spam — a non-empty
_gotchagets422and{"success": false, "message": "Spam detected."}, and nothing is stored. -
Over quota —
429and{"error": "Monthly limit exceeded"}. Note that this is a distinct code from spam, on purpose: one is the visitor's problem and one is yours, and a form that fails silently teaches you nothing. -
Reply-To is taken from an
emailfield in the submission if it parses as an address — so replying from your inbox reaches the person who wrote, whileFrom:stays on an authenticated domain. -
Field names starting with
_are control fields and are never stored with the submission. -
Files are capped at 5 MB each and limited to
jpeg,png,gif,pdf. On the free plan a submission with an attachment is still accepted and stored — the text is never thrown away because of a plan limit — but the file itself isn't kept. - Limits: free is 1 form and 100 submissions a month; Pro is $12/month (or $120/year) for 10 forms, 10 000 submissions, stored attachments and webhooks.
Picking one
- Static site, contact form, don't want to think about it again → a form backend.
- Already have a server or a serverless platform, and want the data to stay yours → your own endpoint, and budget an afternoon for SPF/DKIM.
- A survey rather than a contact form → a form builder, linked to rather than embedded.
-
mailto:→ no.
Whichever you pick, test it the boring way: submit the real form from a phone on mobile data, and check the spam folder of the address you're sending to. Most broken contact forms were tested exactly once, from the developer's own machine, on the day they were built.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.