A search for a simple HTML contact form returns the same twenty lines everywhere, and those twenty lines are correct. They are also the part of the job that takes ten minutes. The rest of the job, deciding where the submission goes, whether anyone gets told, and how the reply gets tracked when three people can see the same inbox, is where the next six months are spent.
This article gives the markup first, because that is what was searched for. Then it deals with the part that the markup cannot do, which is everything after the visitor presses the button.
The markup, and the attributes that actually matter
A contact form that validates on a phone, works without JavaScript, and is readable by a screen reader looks like this.
<form action="https://example.com/submit" method="post">
<label for="name">Your name</label>
<input id="name" name="name" type="text" autocomplete="name" required>
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="subject">Subject</label>
<input id="subject" name="subject" type="text">
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
<button type="submit">Send</button>
</form>
Four things in there are doing real work, and they are the four that get dropped when the markup is copied in a hurry.
The name attribute on every field. Without it the field is simply not sent. The browser will not warn anyone. A form that appears to work and silently drops the message field is a common outcome of copied markup, and it is discovered weeks later by the person who wonders why nobody has written in.
The label tied to the input with for and id. This is what makes the caption clickable, what a screen reader reads out, and what stops a placeholder being used as a label. Placeholder text disappears the moment someone starts typing, so a form labelled only with placeholders becomes unreadable exactly when it is being filled in.
type="email" on the address field. It brings up the keyboard with the at sign on a phone and gets a free format check from the browser. It is not a real validation, because a@b passes, but it catches the missing at sign, which is the mistake that actually happens.
autocomplete values. name and email let the browser fill both fields from the saved profile. On a phone this is the difference between a form that takes four seconds and one that takes forty, and it is two attributes.
What is deliberately absent is any styling, any JavaScript, and any framework. None of those are needed for a form to work, and adding them before the submit path is settled means debugging two things at once.
The four ways to make the submit go somewhere
The action attribute is where the whole question sits. A form with no server behind it is a button that reloads the page. There are four answers, and they are not equally good.
| Route | What you write | Where responses are kept | Ongoing cost |
|---|---|---|---|
mailto: in the action |
Nothing else | Nowhere | None, and it does not reliably work |
| Your own server script | A handler plus a sending service | Wherever you put it | Your time, every time it breaks |
| A form backend service | One URL in the action | The vendor dashboard, often time limited | Free tier, then a monthly fee |
| A hosted form with management | No markup at all | A list with owners and statuses | Priced by seats or by responses |
Why mailto: is not the answer
action="mailto:[email protected]" is the first suggestion in a lot of old tutorials, and it behaves like this: the browser hands the submission to whatever desktop mail program is configured, which opens a draft that the visitor then has to send themselves. If no mail program is configured, and on most phones and most work machines none is, nothing happens at all. The visitor sees a blank tab or a download prompt. The field names arrive mangled. Nobody presses send.
It fails in the most expensive way available, which is silently, on someone else's machine, with no record anywhere that a message was attempted.
Your own handler
A short server script that reads the fields and passes them to a transactional email service is perfectly reasonable, and it is the only route that keeps the data entirely under your control. The work is not the script. The work is what surrounds it: rejecting spam, keeping the send credentials out of the repository, handling the case where the email service is down so the submission is not lost, and writing the response somewhere durable rather than only into an email.
That last point is the one that gets skipped, and it is the one that hurts. An inbox is a poor database. It cannot be filtered by status, it does not say who is dealing with what, and it cannot be exported into a report.
A form backend
These services exist to be the missing server. The form stays as your markup, on your page, styled by your stylesheet, and the action points at an endpoint they give you. Formspree is the long standing example: its free tier allows 50 submissions a month with a 30 day archive and one team member, and the paid tiers run from 15 USD a month for 200 submissions up to 90 USD a month for 20,000, with lower rates when billed yearly. File uploads are limited to five files per submission at 25 MB each. Those figures are on the Formspree plans page.
Hosting platforms offer the same thing with less setup if the site already lives there. Netlify picks up a form from an attribute in the markup and now counts submissions against the same pool of monthly usage credits as builds and bandwidth, rather than giving forms their own separate allowance.
The trade is clear enough. You keep your markup and you accept a monthly ceiling on submissions, which is a meter that rises exactly when the form is working.
The fields that decide whether a reply is even possible
Two details in the markup determine whether answering is easy or annoying, and both are usually got wrong.
The first is the name of the email field. Several form services key their reply behaviour on a field called exactly email. Rename it to your-email or contact-address and the notification that arrives has the service as its reply address instead of the person who wrote in, so pressing reply sends the answer to a robot. It is a one word fix that nobody finds until the third missed reply.
The second is whether the visitor is asked anything that would let the message be routed. A form with name, email and message produces a pile of undifferentiated text. Adding one required select, with three or four options that match how the work is actually divided, turns the same pile into something sortable on arrival. Ask what kind of enquiry it is, not what department it should go to, because visitors do not know your departments.
<label for="topic">What is this about?</label>
<select id="topic" name="topic" required>
<option value="">Please choose</option>
<option value="quote">A quote for work</option>
<option value="support">Something is not working</option>
<option value="invoice">Billing or an invoice</option>
<option value="other">Something else</option>
</select>
Keep the list short. Every extra option is a decision asked of someone who wants to type a sentence and leave, and a list of twelve categories produces the same undifferentiated pile as no list at all, because everyone picks the last one.
Spam, and the part of it the markup can handle
Any public form gets automated submissions within days of going live. Two cheap defences sit in the markup itself.
A honeypot is a field that is hidden from people and left visible to scripts. Bots fill in everything they find, so a submission with anything in that field is discarded.
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label for="website">Leave this empty</label>
<input id="website" name="_gotcha" type="text" tabindex="-1" autocomplete="off">
</div>
Hide it with a position offset rather than display:none, since some scripts skip fields that are set to none, and keep it out of the tab order so keyboard users never land in it.
The second is a timing check, which needs one hidden field holding the moment the page rendered and one comparison on the server. A form completed in under two seconds was not completed by a person.
Neither of these stops a determined attacker, and neither needs to. They stop the volume traffic, which is what makes a public form unusable. A challenge widget can go on later if the volume survives, at the cost of making every honest visitor solve a puzzle.
The part nobody plans for, which is the second week
A working form produces a stream of email into one inbox. For the first fortnight this is fine, and for a single person answering ten messages a week it stays fine indefinitely. The moment two people share the inbox, three specific failures appear, and they appear in the same order every time.
The first is the double reply. Two people open the same message within the hour and both answer, differently. The person who wrote in now has two versions of the truth and no idea which one holds.
The second is the dropped message. Everyone assumed someone else had it. There is no field anywhere that says otherwise, so nothing surfaces the gap until the person writes again, more annoyed.
The third is the missing history. Someone asks what was promised to a given enquirer in March. Answering means searching a personal mailbox, which means the answer depends on who is asked, and anyone who has left the company has taken their half of the record with them.
None of these are email problems exactly. They are the absence of two fields. A response needs an owner, so that exactly one person is answerable for it, and a status, so that new, waiting and done are visibly different states rather than a shared assumption. Teams supply both by hand for a while: a colour in a spreadsheet, a label in the inbox, a message in a group chat saying who has got this one. It works at twenty a month and collapses somewhere around two hundred, which is also the point at which nobody can say when it started going wrong.
A form tool with response management puts those two fields next to the submission and keeps the sent replies attached to it, which is why the same list can answer both who is handling this and what was said in March. The features that matter after the submit are ordinary once they exist: a list that can be filtered by owner, a stage that changes as work moves, and every reply kept on the record rather than in one person's sent items. The way that looks in practice is easier to judge from the worked examples than from a feature list.
What to change first
Ship the twenty lines of markup with real name attributes, real labels, and a form backend behind the action, because a working form today beats a perfect one next month. Then decide, before the volume arrives rather than after, where the owner and the status are going to live, since an inbox will not hold them. If a second person is going to touch these messages, try it with owners and statuses already attached and see whether the spreadsheet column was ever worth keeping. Pricing for that is by the number of people, not the number of responses, which is the meter that does not punish a form for working.
Q1. Can an HTML form send an email on its own, without any server?
No. HTML describes the fields and where to send them; it has no ability to send mail. The mailto: action looks like an exception but is not, since it only hands a draft to a mail program on the visitor's own machine and does nothing at all when none is configured. Sending requires something on the receiving end, which is either your own script or a form service.
Q2. What is the minimum a simple contact form needs?
A form element with an action and method="post", and inside it a name field, an email field with type="email", a message textarea, and a submit button. Every field needs a name attribute or its contents are not sent, and every field needs a label tied to it with for and id. That is the whole requirement; everything else is refinement.
Q3. Why does pressing reply on the notification email not reach the person who wrote in?
Because the notification was sent by the form service, not by the visitor, so the reply address belongs to the service. Most services fix this automatically if the form contains a field named exactly email, and quietly stop doing so if that field is renamed. Check the field name first whenever replies start bouncing or going nowhere.
Q4. Is a honeypot field enough to stop spam?
It stops the bulk of automated submissions, which is most of the problem, and it costs nothing in visitor effort. It will not stop a script written for your specific form. Add a timing check next, since it is also invisible, and only add a challenge widget if volume survives both, because that one is paid for by every honest visitor.
Q5. Should the form be embedded from a tool or written as HTML?
Written HTML gives exact control of markup and styling and needs a backend behind it. An embedded form arrives with validation, conditional logic, file uploads and a response list already working, at the cost of being someone else's markup in a frame. If the page design is the priority, write it. If what happens after the submit is the priority, embed it.
