Static sites are fantastic: fast, cheap to host, secure, and dead simple to deploy. Then a client says “can visitors email us from the site?” and suddenly you’re staring at the one thing a static site can’t do on its own: receive a form submission.
The good news is you don’t need a backend or server to do this. By the end of this guide you’ll have a fully working, accessible, spam-resistant contact form built from plain HTML and a little vanilla JavaScript, with complete copy-paste code, not placeholders.
The options for handling forms without a backend
There are really four common approaches, and they’re not equal.
1. mailto: links. Zero setup. <a href="mailto:you@example.com"> opens the visitor’s email client. But it’s a rough experience: it depends on a configured desktop mail app, publishes your address for scrapers to harvest, and collects nothing structured. Fine for a personal landing page, not for a real contact form.
2. Google Forms embed. Free and no code, but the iframe looks nothing like your site, works against your design and performance, and the UX is clearly “this is a Google Form.” Fine for a hobby project, weak for anything that needs to look professional.
3. Host-native form handling. Netlify Forms and a few other hosts offer built-in form capture. Genuinely nice, if you’re on that host. The moment you move to GitHub Pages, Cloudflare Pages, or your own S3 bucket, it’s gone. You’re locked in.
4. A form-backend API. You point your <form> at an endpoint; the service receives submissions, filters spam, and emails you (or fires a webhook). Host-agnostic, works with any framework, and keeps your markup 100% yours. This is the approach that works everywhere, so it’s what we’ll build.
Step 1: The HTML form (accessible by default)
Here’s a complete, accessible form. Note the real <label> elements and the for/id pairing, which is the part most tutorials skip.
<form
action="https://api.formpaste.com/submit"
method="POST"
id="contact-form"
>
<!-- Your access key from the dashboard -->
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" autocomplete="name" required />
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit">Send message</button>
</form>
That’s a working form already. With no JavaScript at all, a submission POSTs to the endpoint and lands in your inbox. Progressive enhancement done right: it works before a single line of JS runs.
By default the endpoint answers with JSON. Without JavaScript on the page the browser will simply display that JSON response, which is not the experience you want, so see Step 3 for how to send the visitor to a thank-you page instead.
Step 2: Enhance it with JavaScript (proper loading and error states)
The plain form causes a full page navigation. To keep users on the page with inline feedback, enhance it. Notice this actually selects the form by its id, handles the network failure case, and gives real status feedback, which are the details that separate a working form from a demo.
<form action="https://api.formpaste.com/submit" method="POST" id="contact-form">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" id="name" name="name" required />
<input type="email" id="email" name="email" required />
<textarea id="message" name="message" required></textarea>
<button type="submit">Send message</button>
<p id="form-status" role="status" aria-live="polite"></p>
</form>
<script>
const form = document.getElementById("contact-form");
const statusEl = document.getElementById("form-status");
const button = form.querySelector('button[type="submit"]');
form.addEventListener("submit", async (e) => {
e.preventDefault();
button.disabled = true;
statusEl.textContent = "Sending…";
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
if (response.ok) {
statusEl.textContent = "Thanks! Your message was sent.";
form.reset();
} else {
// The API returns { success: false, code, message } on failure.
const data = await response.json().catch(() => null);
statusEl.textContent = data?.message ?? "Something went wrong. Please try again.";
}
} catch (error) {
statusEl.textContent = "Network error. Please try again.";
} finally {
button.disabled = false;
}
});
</script>
The aria-live="polite" region means screen readers announce the status change, which is a free accessibility win.
A successful response is JSON shaped like this:
{ "success": true, "message": "Submission received.", "id": "..." }
Errors use the same envelope with a machine-readable code, so you can branch on data.code if you want distinct messages for rate_limited versus domain_not_allowed.
Step 3: Redirect vs. AJAX, pick your success flow
There are two ways to confirm a submission, and the choice is made by the request itself, not by a header.
JSON (the default). Post the form and you get { success, message, id } back with a 200. That’s what the JavaScript example above relies on: intercept the submit, fetch, show an inline message, never leave the page.
Redirect. Add a redirect field to the form and a successful submission answers with a 303 See Other to that URL, so the browser lands on your thank-you page. This is the no-JS path.
<input type="hidden" name="redirect" value="/thank-you" />
Two rules worth knowing, because both fail closed:
- A relative URL like
/thank-youalways works. - An absolute URL like
https://example.com/thank-youonly works if that domain is on your form’s allowlist. With an empty allowlist, absolute redirects are rejected outright. This is deliberate: it stops a stolen key from bouncing your visitors to a phishing page.
An invalid redirect returns 400 invalid_redirect and nothing is stored, so you’ll notice immediately rather than silently losing submissions.
Use redirect for maximum simplicity and resilience; use JSON for a smoother single-page feel.
Stopping spam without CAPTCHA
CAPTCHA puzzles annoy real users and hurt conversions. You can stop most bot spam with three quiet techniques, no puzzles required.
Honeypot field: a hidden input real users never fill in, but bots do. Hide it from humans and screen readers, then reject any submission where it’s filled. The field name has to match whatever your provider looks for. Formpaste uses botcheck:
<!-- Bots fill this; humans never see it -->
<input
type="text"
name="botcheck"
style="position:absolute; left:-9999px;"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
If you invent your own field name, the server has no idea it’s a honeypot and will just store it as an ordinary form field. Check your provider’s docs for the exact name; this is a quiet failure that only shows up later as spam in your inbox.
Timing check: legitimate users take a few seconds to fill a form; bots submit in milliseconds. Record when the form loads and flag submissions that arrive suspiciously fast. Formpaste reads this from a _ts field holding the page-load timestamp:
<input type="hidden" name="_ts" id="form-ts" />
<script>
document.getElementById("form-ts").value = Date.now();
</script>
Rate limiting: cap submissions per IP per minute so a bot can’t hammer your endpoint. This one genuinely requires the server side.
Implementing all three yourself means running server logic, which defeats the “no backend” goal. This is where a form-backend API earns its keep: good ones run honeypot, timing, rate-limit, disposable-email, and duplicate checks for you, then hold anything suspicious in a quarantine you can review rather than silently dropping real messages.
For the full picture, including how the signals are scored and why no single check should ever reject a message on its own, see how to stop contact form spam without CAPTCHA.
Common errors (and how to fix them)
“My form submits but I get no email.” Check your spam folder, confirm you verified your sender email, and make sure the access_key value is correct and not still the placeholder.
“I get a 403 domain_not_allowed.” Your form has a domain allowlist and the request came from a domain that isn’t on it. Add the domain in the dashboard. Note this is a normal JSON error response, not a CORS error.
“CORS error in the console.” This is a different problem from the one above, and it’s worth separating them. A form endpoint that’s meant for static sites should send permissive CORS headers, so a genuine CORS failure usually means the request never reached the API at all: a typo’d endpoint URL, a network/DNS failure, or an adblocker. Check the Network tab to see whether a response came back at all. Also worth knowing: CORS is a browser policy, not a security boundary. It doesn’t protect your key, and the domain allowlist is what actually restricts who can submit.
“Nothing happens on submit.” If you’re using the JS version, confirm your getElementById/selector actually matches the form, and that you called e.preventDefault().
“It works locally but not in production.” Almost always the domain allowlist. Add your production domain.
Choosing a service
A quick, honest lay of the land:
Formspree is mature and popular, with a generous ecosystem. A safe default, though the free tier is limited. Web3Forms is genuinely no-signup and access-key based, great for quick throwaway forms. Formpaste (what I used here) leans into paste-a-component simplicity, CAPTCHA-free spam filtering with a quarantine you control, and, unusually, an MCP server so an AI coding agent can wire the form up for you. Netlify Forms is excellent but only if you’re already hosting on Netlify.
Any of them work with every code sample above. Swap the endpoint and key, and check each provider’s docs for its own field names, since honeypot and redirect conventions differ between services.
Wrapping up
Adding a contact form to a basic HTML static site no longer means standing up a server. You’ve now got a complete, accessible, progressively-enhanced form, a CAPTCHA-free spam strategy, and a fix-list for the usual gotchas.
Start with the plain HTML form, enhance with JavaScript when you want inline feedback, and let a form-backend API handle delivery and spam so you can get back to building.
Using a framework? The same endpoint and the same fields work everywhere. These walk through the idiomatic version for each, along with the gotchas specific to it: React, Next.js, Vue, Astro and Svelte.