A contact form takes about fifteen lines of HTML. Most tutorials stop there, and that is why so many contact forms quietly lose messages: HTML only collects the fields. When someone presses Send, the browser posts the data to the URL in the form's action attribute, and something at that URL has to store it and tell you about it.
This guide covers both halves: a clean, accessible form, and a backend that receives each message and emails it to you. It works on any static site, with no PHP and no server to run.
Step 1: Write the form markup
Start with the three fields every contact form needs: name, email, and message.
<form class="contact-form" action="" method="POST"> <label for="name">Name</label> <input id="name" name="name" type="text" autocomplete="name" required> <label for="email">Email</label> <input id="email" name="email" type="email" autocomplete="email" required> <label for="message">Message</label> <textarea id="message" name="message" rows="5" required></textarea> <button type="submit">Send message</button> </form>
A few details make this form easier to use than most:
- Every input has a
<label>tied to it withforandid. Screen readers announce it, and clicking the label focuses the field. type="email"brings up the email keyboard on phones and lets the browser check the format before sending.autocomplete="name"andautocomplete="email"let browsers fill the fields in one tap.- The
nameattribute is what gets sent. A field without anameis silently left out of the submission.
Step 2: Add some basic styling
Unstyled forms work, but they look broken. This CSS stacks the fields, gives them room, and adds a visible focus ring for keyboard users.
.contact-form {
display: grid;
gap: 0.5rem;
max-width: 32rem;
font: 16px/1.5 system-ui, sans-serif;
}
.contact-form label {
margin-top: 0.5rem;
font-weight: 600;
}
.contact-form input,
.contact-form textarea {
padding: 0.625rem 0.75rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
font: inherit;
}
.contact-form input:focus,
.contact-form textarea:focus {
outline: 2px solid #2563eb;
outline-offset: 1px;
}
.contact-form button {
margin-top: 1rem;
padding: 0.75rem 1rem;
border: 0;
border-radius: 0.5rem;
background: #111827;
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.contact-form button:disabled {
opacity: 0.6;
}Prefer ready-made designs? The contact form templates are styled with Tailwind CSS and already wired to Heapform.
Step 3: Decide where the submissions go
The action is still empty, so the form posts back to the same page and the message disappears. You have three options:
action="mailto:you@example.com"opens the visitor's email app instead of sending anything. Anyone without a configured email app can't contact you, and you keep no record.- Your own server script (PHP, Node, a serverless function) works, but you have to handle validation, spam, email deliverability, and storage yourself.
- A form backend gives you a URL to post to. It stores each submission, emails you, and filters spam.
This guide uses Heapform, the form backend we build. To connect the form:
- Create a free account and create a form in the dashboard.
- Add your email address as a notification recipient in the form's settings.
- Copy the form's endpoint URL and paste it into the form's action:
<form class="contact-form" action="https://forms.heapform.com/f/YOUR_FORM_SLUG" method="POST">
Publish the page and send a test message. It appears under Submissions in the dashboard, and the notification email arrives a few seconds later.
Step 4: Show a thank-you message
After a plain HTML submit, the browser navigates to the response. On the Starter plan and above, set a return URL in the form settings and visitors are redirected to your own thank-you page. No code needed.
On any plan, including Hobby, you can keep visitors on the page and show the message inline. Add a status paragraph and a short script below the form:
<p id="form-status" role="status" hidden></p>
<script>
const form = document.querySelector(".contact-form");
const status = document.getElementById("form-status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
try {
const response = await fetch(form.action, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const result = await response.json();
if (result.success && result.returnUrl) {
window.location.href = result.returnUrl;
return;
}
if (result.success) {
form.reset();
status.textContent = "Thanks! Your message has been sent.";
} else {
const firstError = Object.values(result.errors ?? {})[0];
status.textContent = firstError?.message ?? "Please check the form and try again.";
}
} catch {
status.textContent = "Something went wrong. Please try again.";
} finally {
status.hidden = false;
button.disabled = false;
}
});
</script>The script sends the fields as JSON, clears the form on success, and shows Heapform's validation message if a field fails a rule you set in the dashboard. If you configured a return URL, it follows it. The role="status" attribute makes screen readers announce the result.
Step 5: Keep spam out
Any public form gets found by bots within days. Add a honeypot: a field that people never see, but bots fill in anyway.
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">
Heapform spam filtering is on by default. It also checks for link-stuffed messages, throwaway domains, scripted clients, and bursts from one IP. Flagged submissions are kept in the dashboard but don't email you and don't count toward your quota. If a form still gets through, turn on Cloudflare Turnstile, hCaptcha, or reCAPTCHA in its settings.
The complete contact form
Here is the finished markup. Replace YOUR_FORM_SLUG, add the CSS from step 2 and, optionally, the script from step 4.
<form class="contact-form" action="https://forms.heapform.com/f/YOUR_FORM_SLUG" method="POST"> <label for="name">Name</label> <input id="name" name="name" type="text" autocomplete="name" required> <label for="email">Email</label> <input id="email" name="email" type="email" autocomplete="email" required> <label for="message">Message</label> <textarea id="message" name="message" rows="5" required></textarea> <!-- Honeypot: hidden from people, filled in by bots --> <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none"> <button type="submit">Send message</button> </form>
The Hobby plan covers 100 submissions a month, which is plenty for most personal and small business sites. Next, see how to send form submissions to more than one inbox or build the same form in Next.js.