
Simple Contact Form for Website: Build Yours with HTML &
You've got a static site deployed, the pages are fast, Lighthouse is clean, and then the annoying part shows up: people need a way to contact you. A mailto: link is easy, but it dumps the user into their local email client, exposes your address to scrapers, and gives you no control over validation, spam filtering, or post-submit UX.
A simple contact form for a website solves that, but only if you treat it like a production feature from the start. The hard part isn't the <form> tag. It's delivery, spam, confirmation, privacy, and making sure submissions land where your team can act on them.
Going Beyond a Basic Contact Page
For JAMstack and static sites, a contact form sits in an awkward spot. The frontend is simple. The backend requirement is tiny. But once you need email notifications, validation, and spam control, “tiny” turns into a maintenance burden fast if you build it yourself.
That's why form backends became a standard pattern for static sites. You keep plain HTML on the frontend, point the form at a hosted endpoint, and let that service handle submission processing, notifications, storage, webhooks, and anti-spam checks. The shape is simple: browser posts form data, provider validates it, then routes it to email, a dashboard, or another tool.
The UX side matters too. Users usually choose the path that feels quickest. In contact preference research, 67.3% of respondents chose email when a site offered both a contact form and an email address, which is a useful reminder that your form has to earn its place through speed and clarity, not just existence (Network Solutions contact form research).
Practical rule: If your form feels slower than sending an email, people will avoid it.
A decent production baseline looks like this:
- Keep the form short: Ask for only what you need to reply.
- Make delivery reliable: Don't trust browser success states alone.
- Confirm submission: Show users what happened after they click submit.
- Handle spam early: Public endpoints get abused.
- Route submissions somewhere useful: Email inbox, Slack, Sheets, CRM, or webhook.
What doesn't work is the common fake-minimal setup: a nice-looking form with no anti-spam, no auto-response, no meaningful error handling, and no audit trail when a client says, “I sent this last week.”
That setup looks done on launch day. It breaks on day two.
Building Your HTML Form Foundation
The markup should be boring. That's a good thing. A contact form isn't the place for clever UI patterns, floating labels that break accessibility, or multi-column layouts that make scanning harder.

A practical pattern is a single vertical column with labels above fields. UX guidance summarized by UXmatters points to that structure as easier to scan and complete because it reduces cognitive load and keeps the reading flow predictable (UXmatters form design guidance).
Start with semantic HTML
Here's a copy-pasteable base form:
<form
action="https://example-form-endpoint.com/submit"
method="POST"
accept-charset="UTF-8"
>
<div>
<label for="name">Name</label>
<input
id="name"
name="name"
type="text"
autocomplete="name"
required
/>
</div>
<div>
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
inputmode="email"
required
/>
</div>
<div>
<label for="message">Message</label>
<textarea
id="message"
name="message"
rows="6"
required
></textarea>
</div>
<button type="submit">Send message</button>
</form>A few details matter here:
label+forpairing: Screen readers need the association. So do users clicking labels on mobile.- Useful
nameattributes: The backend receives keys fromname, notid. - Correct input types:
type="email"gives you browser validation and better mobile keyboards. - Native submit behavior: Start with plain HTML before adding JavaScript.
If you want a walkthrough that stays close to this baseline, this free HTML form guide shows the same hosted-endpoint pattern in a stripped-down setup.
Keep fields minimal and defensible
The usual mistake is adding fields because they might be useful later. Phone number is the classic example. Unless your team uses phone follow-up, it often adds friction and privacy hesitation without improving the conversation.
A tighter default is:
- Name: So the reply feels human.
- Email: Required if you need to answer.
- Message: The actual reason they came.
You can add company, budget, or file upload later if the business case is clear. Don't add them “just in case.”
If you can't explain why a field exists, remove it.
Add the small production details early
Even basic HTML can include some quality-of-life improvements:
<form
action="https://example-form-endpoint.com/submit"
method="POST"
accept-charset="UTF-8"
novalidate
>
<input type="hidden" name="subject" value="New website contact submission" />
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="name" required />
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<p>
By submitting this form, you agree that we may use your details to reply to your message.
</p>
<button type="submit">Send message</button>
</form>Use novalidate only if you plan to replace native browser messaging with your own accessible validation flow. Otherwise, leave it off.
Connecting Your Form to a Backend Service
A form without a processing endpoint is just UI. Something has to receive the POST request, validate the payload, block obvious spam, and deliver the message somewhere useful.
For static sites, you've got a few realistic options:
| Option | When it fits | Trade-off |
|---|---|---|
| Hosted form backend | Static sites, fast setup, no server code | Third-party dependency |
| Serverless function | You want full control | More code, monitoring, and maintenance |
| Full custom backend | Complex workflows or internal systems | Highest overhead |
If your site is mostly static and the form is a supporting feature, a hosted backend is often the practical call. Tools in this category include Formspree, Getform, Basin, Web3Forms, and Static Forms. The pattern is the same across them: create a form in the dashboard, get an endpoint or API key, and wire it into the HTML.

The plain HTML wiring
With a hosted backend, the form usually changes in only a few places. Here's a realistic example using a public API endpoint and hidden API key:
<form
action="https://api.staticforms.dev/submit"
method="POST"
accept-charset="UTF-8"
>
<input type="hidden" name="apiKey" value="YOUR_API_KEY" />
<input type="hidden" name="redirectTo" value="https://yourdomain.com/contact/thanks" />
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<button type="submit">Send message</button>
</form>That's the whole point of this model. You don't need an app server just to receive a few fields and forward them by email.
What the backend should handle for you
When I review form setups, I look for five things immediately:
Submission acceptance
The endpoint should accept standardapplication/x-www-form-urlencodedormultipart/form-dataposts so plain forms work without extra JS.Notification delivery
Somebody on your team has to receive the message, and that path needs to be stable enough for client sites.Redirect or confirmation support
Users should land on a success state you control, not a blank browser tab or raw JSON response.Spam controls
Honeypot, CAPTCHA support, or both.Operational visibility
If a message goes missing, you need logs or a dashboard inbox, not guesswork.
A contact form is part UI, part delivery pipeline. Most bugs happen in the second part.
Redirects, thank-you pages, and error states
Don't stop at “form submits.” Decide what happens next.
A good post-submit path usually includes:
- A success page or inline success message: Confirms the message was received.
- Clear next-step copy: Tell the user how you'll respond.
- An error state: Explain what to do if submission fails.
- No false positives: Don't show success before the backend accepts the request.
A thank-you page also gives you a clean place for analytics events, alternate contact info, or support documentation links.
When a backend service is the wrong choice
There are cases where hosted form handling isn't enough:
- You need complex conditional routing based on internal data.
- You need custom authentication before submission.
- You must run proprietary server-side checks before accepting the message.
- Your organization requires strict internal-only processing.
In those cases, use a serverless function or your own backend. But for a normal simple contact form for a website, especially on static hosting, that extra build surface usually isn't worth it.
Choosing the Right Spam Protection
The minute your form goes live, bots will find it. Some will post junk URLs. Some will try credential dumps. Some will send random text to test whether the endpoint is open. Spam protection isn't a nice-to-have. It's part of shipping the feature.

Industry guidance for operating forms at scale recommends using a clear confirmation flow, automated acknowledgment, and optional spam controls such as reCAPTCHA or honeypot fields, because the form experience includes what happens after submit, not just before it (ABCSubmit contact form best practices).
If you want a provider-focused walkthrough of common bot patterns and filtering approaches, this spam email bot guide is a useful reference.
Honeypot versus CAPTCHA
Here's the practical comparison:
| Method | UX cost | Protection level | Good default |
|---|---|---|---|
| Honeypot | None for humans | Basic to moderate | Yes |
| reCAPTCHA v2 | Visible friction | Strong | Sometimes |
| reCAPTCHA v3 | Low visible friction | Depends on scoring | Good when tuned |
| Cloudflare Turnstile | Lower friction | Strong | Often a solid middle ground |
| Time checks | Invisible | Limited alone | Only as a secondary layer |
For most sites, I start with honeypot plus server-side validation. If spam gets through, I add Turnstile or reCAPTCHA.
Honeypot implementation
A honeypot is just a field humans shouldn't fill. Bots often will.
<form action="https://api.example.com/submit" method="POST">
<div style="position:absolute;left:-9999px;" aria-hidden="true">
<label for="company">Company</label>
<input
id="company"
type="text"
name="company"
tabindex="-1"
autocomplete="off"
/>
</div>
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
</div>
<button type="submit">Send</button>
</form>On the backend side, reject the submission if company contains a value.
This method is cheap, invisible, and worth using even if you later add CAPTCHA. Just don't hide the field with display:none if your anti-spam vendor specifically checks for more natural hidden patterns. Follow the provider's docs.
Turnstile and reCAPTCHA trade-offs
CAPTCHAs catch more bots, but they add friction and can hurt accessibility if implemented badly.
reCAPTCHA v2 gives a visible widget or challenge. It's stronger from a blocking perspective, but it interrupts the flow.
reCAPTCHA v3 runs in the background and returns a score. Better UX, but you need backend scoring logic and thresholds.
Cloudflare Turnstile is a strong option when you want bot protection with less visible hassle. It fits well on modern static sites.
A minimal Turnstile setup looks like this:
<form action="https://api.example.com/submit" method="POST">
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
</div>
<div
class="cf-turnstile"
data-sitekey="YOUR_TURNSTILE_SITE_KEY"
></div>
<button type="submit">Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>What actually works in production
Don't rely on one trick. Use layers.
- First layer: Required fields, type validation, message length checks.
- Second layer: Honeypot.
- Third layer: CAPTCHA if the site attracts repeated spam.
- Fourth layer: Provider-side filtering, rate limiting, and webhook validation where available.
The right anti-spam setup is the lightest one that keeps your inbox usable.
Configuring Notifications and Advanced Workflows
A submitted form should trigger action, not just generate an email. Too often, forms fail in this way: the browser reports success, yet no team member sees the message, the submitter receives no confirmation, and the inquiry remains in a personal inbox until someone notices it.
That gap is common. In lead-management research, only 15.6% of businesses sent an auto-reply after a form submission, even though that one step confirms receipt and lowers uncertainty for the person who just contacted you (Leadferno research on form follow-up).

Send notifications to the right place
The first configuration decision is simple: who gets the message?
For a solo founder, one inbox may be enough. For a team, route based on purpose. Sales inquiries can go one place, support another, partnership requests somewhere else. If your provider doesn't offer conditional routing, use a webhook into automation tools that do.
A sensible notification setup often includes:
- Primary email recipient: The person or shared inbox that owns the response.
- Fallback visibility: A dashboard inbox or archived submission store.
- Structured subject lines: Easy to scan and filter.
- Field normalization: Consistent names so automations don't break.
Auto-replies are part of the product
An auto-responder does three jobs:
- Confirms the message was received.
- Tells the user what happens next.
- Reduces duplicate submissions.
Keep it plain. Don't turn it into marketing copy.
Subject: We got your message
Hi {{name}},
Thanks for reaching out. We received your message and will reply by email.
If your request is urgent, you can also contact us at support@yourdomain.com.
Message received:
{{message}}Short beats clever here.
A confirmation email closes the loop your form opened.
Webhooks turn a form into a workflow
Once the submission leaves the browser, email is only one possible destination. A webhook lets you POST the payload to another service, which is where forms become operational tools instead of inbox feeders.
Common webhook destinations:
- Slack: Notify a team channel immediately.
- Google Sheets: Keep a lightweight record for lead triage.
- Zapier, Make, or n8n: Fan submissions out into CRMs, task tools, or ticketing systems.
- Custom endpoint: Run your own logic, tagging, or enrichment.
A webhook payload usually looks like this:
{
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "I'd like to discuss a project.",
"submittedAt": "2026-06-04T10:00:00Z"
}If you control the receiving endpoint, validate signatures or shared secrets when the provider supports them. Don't accept unauthenticated POSTs blindly just because they look internal.
File uploads and practical constraints
File upload changes the form's complexity immediately. It affects payload size, spam risk, storage, and your email workflow.
If your provider supports uploads, keep the rules obvious:
- Accept only needed types: PDFs, docs, images, not everything.
- Keep the limit clear: Some hosted providers support uploads up to 5MB per submission.
- Use
multipart/form-data: Required for real file transport. - Don't rely on email attachments alone: Prefer links or managed storage when available.
Example:
<form
action="https://api.example.com/submit"
method="POST"
enctype="multipart/form-data"
>
<div>
<label for="attachment">Attachment</label>
<input
id="attachment"
name="attachment"
type="file"
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
/>
</div>
</form>Deliverability and custom-domain email
If the form backend sends notifications or auto-replies from your brand domain, email authentication matters. SPF, DKIM, and DMARC help receiving mail systems trust that those messages are legitimate.
The practical rule is simple: if you want branded notification emails and auto-responders to land reliably, configure your sending domain correctly and test it with real inboxes. Without that, even a working form can look flaky because delivery quality is inconsistent.
Privacy matters here too. If you store submissions, know where that data lives, who can access it, and how long you keep it. Contact forms collect personal data quickly, even when the UI looks simple.
Integrating Your Form with Modern Frameworks
Plain HTML should always be your starting point. But in React, Next.js, or Vue, you may want async submission, inline validation, and a better pending or success state without a full-page reload.
That's fine. Just don't rebuild the browser's form behavior badly.
Independent UX guidance also recommends asking for only the minimum fields you really need, and specifically avoiding phone-number collection unless there's a strong reason, because many users hesitate to share it due to spam-call concerns (privacy-minimal form design guidance).
React example with fetch
This pattern keeps the form controlled enough to show state, but not so abstract that it's hard to debug.
import { useState } from "react";
export default function ContactForm() {
const [form, setForm] = useState({
name: "",
email: "",
message: "",
});
const [status, setStatus] = useState("idle");
async function handleSubmit(e) {
e.preventDefault();
setStatus("submitting");
const response = await fetch("https://api.staticforms.dev/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
apiKey: "YOUR_API_KEY",
name: form.name,
email: form.email,
message: form.message,
}),
});
const data = await response.json();
if (data.success) {
setStatus("success");
setForm({ name: "", email: "", message: "" });
} else {
setStatus("error");
}
}
function handleChange(e) {
setForm((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" value={form.name} onChange={handleChange} required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" value={form.email} onChange={handleChange} required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" value={form.message} onChange={handleChange} required />
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "Sending..." : "Send message"}
</button>
{status === "success" && <p>Your message was sent.</p>}
{status === "error" && <p>Something went wrong. Please try again.</p>}
</form>
);
}Next.js client component example
In Next.js App Router, keep this in a client component.
"use client";
import { useState } from "react";
export default function ContactForm() {
const [pending, setPending] = useState(false);
const [result, setResult] = useState("");
async function onSubmit(e) {
e.preventDefault();
setPending(true);
setResult("");
const formData = new FormData(e.currentTarget);
formData.append("apiKey", "YOUR_API_KEY");
const response = await fetch("https://api.staticforms.dev/submit", {
method: "POST",
body: formData,
});
const data = await response.json();
if (data.success) {
setResult("success");
e.currentTarget.reset();
} else {
setResult("error");
}
setPending(false);
}
return (
<form onSubmit={onSubmit}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows="6" required />
</div>
<button type="submit" disabled={pending}>
{pending ? "Sending..." : "Send message"}
</button>
{result === "success" && <p>Thanks. We received your message.</p>}
{result === "error" && <p>Submission failed. Please try again.</p>}
</form>
);
}If you're working in that stack, this Next.js form backend walkthrough is the relevant implementation reference.
Vue example
Vue stays close to the same shape:
<script setup>
import { reactive, ref } from "vue";
const form = reactive({
name: "",
email: "",
message: ""
});
const status = ref("idle");
async function submitForm() {
status.value = "submitting";
const response = await fetch("https://api.example.com/submit", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(form)
});
status.value = response.ok ? "success" : "error";
}
</script>
<template>
<form @submit.prevent="submitForm">
<label for="name">Name</label>
<input id="name" v-model="form.name" name="name" required />
<label for="email">Email</label>
<input id="email" v-model="form.email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" v-model="form.message" name="message" required />
<button type="submit" :disabled="status === 'submitting'">
{{ status === "submitting" ? "Sending..." : "Send message" }}
</button>
<p v-if="status === 'success'">Message sent.</p>
<p v-if="status === 'error'">Something went wrong.</p>
</form>
</template>Webflow and WordPress notes
In Webflow, you can replace the default action with a custom endpoint using embedded code or custom form handling patterns, depending on how much control you need.
In WordPress, a plain Custom HTML block works fine when you don't want a heavy plugin stack. Just make sure the theme doesn't strip attributes you need and that caching or optimization plugins aren't interfering with scripts used for CAPTCHA.
If you want a no-backend way to ship a production-ready contact form on a static site, Static Forms is one option to consider. It accepts plain HTML form posts at https://api.staticforms.dev/submit, works with frameworks and site builders, supports spam controls like honeypot, reCAPTCHA, and Turnstile, and can route submissions to email or other tools without maintaining your own form handler.
Related Articles
How to Embed Contact Form HTML: A Step-by-Step 2026 Guide
Learn how to embed contact form html on your site today. This 2026 guide covers backend connections, spam protection, and easy deployment for your website.
HTML Form Maker: Create a Working Form in Minutes
Use an HTML form maker to build, configure, and deploy a secure form with a serverless backend. Learn to handle submissions, spam, and AI replies.
Stop Spam Email Bots on Static Sites in 2026
Prevent a spam email bot from hitting your static site. Get step-by-step guides for blocking them with honeypots, reCAPTCHA, Turnstile, & more in 2026.