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 you can drop into plain HTML, React, Next.js, Vue, Astro, or Svelte, 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 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 4 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: Framework examples (complete, not placeholders)
React
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("idle"); // idle | sending | success | error
async function handleSubmit(e) {
e.preventDefault();
// Capture the form node now: after the first await, `currentTarget` is null.
const form = e.currentTarget;
setStatus("sending");
try {
const response = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
setStatus(response.ok ? "success" : "error");
if (response.ok) form.reset();
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<label htmlFor="name">Name</label>
<input id="name" type="text" name="name" required />
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send message"}
</button>
{status === "success" && <p role="status">Thanks! Your message was sent.</p>}
{status === "error" && <p role="status">Something went wrong. Try again.</p>}
</form>
);
}
Next.js (App Router)
With the App Router, keep the form as a client component so you can manage submit state. You don’t need a route handler at all, since the form posts directly to the API.
Worth saying plainly: the access key sits in client-side code and is visible to anyone who views source. That’s by design for this kind of endpoint. The key identifies which form a submission belongs to; it isn’t a secret credential, and it can’t be used to read your submissions. Your protection against someone reusing it elsewhere is the domain allowlist, covered below.
"use client";
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
async function handleSubmit(e) {
e.preventDefault();
const form = e.currentTarget; // capture before awaiting
setStatus("sending");
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
setStatus(res.ok ? "success" : "error");
if (res.ok) form.reset();
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send message"}
</button>
{status === "success" && <p role="status">Message sent!</p>}
{status === "error" && <p role="status">Please try again.</p>}
</form>
);
}
Vue 3
This one posts JSON instead of FormData, just to show the endpoint accepts both. Because the values come from v-model state, the access key goes in the JSON body and there’s no hidden input.
<template>
<form @submit.prevent="submitForm">
<label for="name">Name</label>
<input id="name" v-model="form.name" type="text" name="name" required />
<label for="email">Email</label>
<input id="email" v-model="form.email" type="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" v-model="form.message" name="message" required />
<button type="submit" :disabled="status === 'sending'">
{{ status === "sending" ? "Sending…" : "Send message" }}
</button>
<p v-if="status === 'success'" role="status">Thanks! Message sent.</p>
<p v-if="status === 'error'" role="status">Something went wrong.</p>
</form>
</template>
<script setup>
import { reactive, ref } from "vue";
const ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
const form = reactive({ name: "", email: "", message: "" });
const status = ref("idle");
async function submitForm() {
status.value = "sending";
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, access_key: ACCESS_KEY }),
});
status.value = res.ok ? "success" : "error";
if (res.ok) Object.assign(form, { name: "", email: "", message: "" });
} catch {
status.value = "error";
}
}
</script>
Astro
Astro ships zero JS by default, so the plain HTML form works as-is in a .astro file.
---
// src/components/ContactForm.astro
const ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
---
<form action="https://api.formpaste.com/submit" method="POST" id="contact-form">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<input type="hidden" name="redirect" value="/thank-you" />
<label for="name">Name</label>
<input id="name" type="text" name="name" required />
<label for="email">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 message</button>
</form>
Because there’s no JavaScript here, the redirect field matters: without it the browser would land on the raw JSON response. If you’d rather have inline feedback, drop the redirect field and add a client script:
<script>
const form = document.getElementById("contact-form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
form.insertAdjacentHTML(
"beforeend",
res.ok ? "<p role='status'>Message sent!</p>" : "<p role='status'>Please try again.</p>",
);
if (res.ok) form.reset();
});
</script>
Svelte
<script>
let status = "idle";
async function handleSubmit(event) {
const form = event.currentTarget; // capture before awaiting
status = "sending";
try {
const res = await fetch("https://api.formpaste.com/submit", {
method: "POST",
body: new FormData(form),
});
status = res.ok ? "success" : "error";
if (res.ok) form.reset();
} catch {
status = "error";
}
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY_HERE" />
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send message"}
</button>
{#if status === "success"}<p role="status">Message sent!</p>{/if}
{#if status === "error"}<p role="status">Please try again.</p>{/if}
</form>
Step 4: 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 every 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.
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.
“e.currentTarget is null.” Classic async footgun: currentTarget is cleared once the handler returns, so reading it after an await blows up. Capture it into a variable first, as the examples above do.
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, working versions for five frameworks, 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.