You've deployed a site to GitHub Pages, Netlify, or Cloudflare Pages. You add a <form>, a visitor hits Send, and the submission goes nowhere, because there's no server on your side to receive it. That's the whole problem in one sentence: a contact form needs something to POST to, and a static host doesn't give you one.
You have three realistic options: a mailto:link, a serverless function wired to an email API, or a third-party form endpoint. Let's go through them in order of effort, and be honest about where each one breaks.
Option 1: a mailto: link
The zero-effort option is to skip the form entirely:
<a href="mailto:you@example.com">Email me</a>It costs nothing and fails in three ways:
- Spam harvesters read your HTML. A plain-text address on a public page gets scraped by bots, and the spam starts within days. (We wrote about what actually works against scraping separately.)
- It depends on the visitor's mail client.
mailto:opens the system default. On a work laptop or a phone browser, that's often an unconfigured Outlook or nothing at all. The visitor stares at a blank tab and gives up. - You get no structure. No required fields, no subject you chose, no consistent formatting.
<form action="mailto:...">is even worse: browsers handle it inconsistently, and some not at all.
Verdict: fine for a personal homepage where losing messages doesn't matter. Not a contact form.
Option 2: a serverless function + an email API
The DIY route: your host's serverless functions receive the POST, and an email API (Resend, Postmark, SES, take your pick) delivers it to your mailbox. The code is genuinely short:
// netlify/functions/contact.js (or a Vercel function, or a Worker)
export default async function handler(req) {
const { name, email, message } = await req.json();
if (!email || !message) {
return new Response("Missing fields", { status: 400 });
}
// Every email API looks roughly like this
await fetch("https://api.email-provider.example/send", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.EMAIL_API_KEY}` },
body: JSON.stringify({
to: "you@example.com",
subject: `Contact form: ${name || "someone"}`,
reply_to: email,
text: message,
}),
});
return Response.json({ ok: true });
}This works, and if you already run functions it's a reasonable choice. But tally the real cost of that little handler:
- A function to write, deploy, and keep working when runtimes change.
- An account with an email provider, usually with domain verification before delivery is reliable.
- API keys to create, store as environment variables, and rotate.
- Spam handling is your problem. The first bot that discovers the endpoint will use it, and now you're writing rate limits and honeypots.
- All of it duplicated for every site you ship. Project number four still needs its own function, key, and config.
And note what happened along the way: your "static site with no backend" now has a backend. A small one, but you own it.
Option 3: a form endpoint
A form endpoint is a URL that accepts your form's POST and puts the message somewhere you'll actually see it. Your site stays fully static; the service is the backend. The entire integration is one attribute:
<form action="https://mailcontact.app/api/forms/your-form-id" method="POST">
<input type="text" name="name" placeholder="Your name" />
<input type="email" name="email" placeholder="you@example.com" required />
<input type="text" name="subject" placeholder="Subject" />
<textarea name="message" placeholder="What's on your mind?" required></textarea>
<button type="submit">Send</button>
</form>That's a working contact form. With mailcontact's form endpoint, the URL accepts standard form data or JSON, requires a valid email and a message (name and subject are optional), and answers {"ok": true}. CORS is open, so it works from any origin: GitHub Pages, Netlify, an S3 bucket, even a local HTML file. Submissions are rate-limited per IP, and each message lands in your project's inbox.
One practical note: to keep visitors on your page after they hit send, submit the form with fetch instead of a native post (a configurable redirect is coming with the launch):
<script>
document.querySelector("form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.target;
await fetch(form.action, { method: "POST", body: new FormData(form) });
form.outerHTML = "<p>Thanks, your message was sent.</p>";
});
</script>When is this the right trade-off? When the job is "let visitors reach me, and don't make me maintain anything". You're trusting a service instead of your own code. That's the deal. If you need custom logic, attachments, or a pipeline into your CRM, write the function. For a contact form on a static site, the endpoint wins on every axis that matters: setup time, maintenance, and the number of API keys involved (zero). The static-sites guide covers host-specific details.
Getting notified and keeping projects separate
Whichever option you pick, remember why you wanted the form: so you actually read the messages. A form that delivers into a mailbox you never check is a mailto: link with extra steps.
This is where mailcontact's model helps. Each project gets one inbox, and a channel pings you when something arrives: Discord, Slack, email, or a webhook. The same inbox also comes with a real email address (yourproject@mailcontact.app) that works immediately, no DNS or mail server required, so the form and the "or just email us" link feed the same place.
And when you ship the next project, you make a new inbox instead of pointing yet another form at your personal mailbox. We've written up why every side project deserves its own inbox. Short version: separation is what keeps five projects manageable instead of five sources of noise.