How to Add a Contact Form to a Static Website
Make a static site's contact form send real emails with Formspree, Netlify Forms, Basin or a serverless function. Markup, spam protection and testing.
· 8 min read
A static website has no server of its own, so a contact form needs somewhere to send its data. The easiest
fix is a form backend service: you point the form's action at a URL the service gives you, and it emails
each submission to you and keeps a copy in a dashboard. Setting up Formspree, Netlify Forms or Basin takes
about ten minutes and no server code. This guide shows the markup for each, how to submit without leaving
the page, how to keep spam out and how to test it properly. If you'd rather own the whole thing, there's a
short section on serverless functions at the end.
Start with a good form#
Whichever service you choose, the HTML is mostly the same. Every field needs a name, because that's the
key the service receives. Labels make the form usable with a screen reader and easier to tap on a phone.
<form action="YOUR-ENDPOINT" 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>Name the email field email: most services use it to set the reply-to address, so you can answer an
enquiry straight from your inbox.
If you're using a Freeframe template, the forms already have labels, names and validation. Out of the box they don't send anything (the success message says so), and the forms docs show which function to change in each template.
Option 1: Formspree#
Formspree works on any host. At the time of writing, its free plan allows 50 submissions a month, unlimited forms, basic spam filtering and a 30-day submission archive. File uploads need a paid plan.
-
Sign up and create a new form. Formspree gives you an endpoint such as
https://formspree.io/f/abcdwxyz. -
Put that URL in your form's
action:html<form action="https://formspree.io/f/abcdwxyz" method="POST"> -
Deploy the site and submit the form once. If Formspree asks you to confirm your email address, do so; after that, submissions arrive in your inbox.
With a plain HTML submit like this, the visitor is taken to a Formspree thank-you page. To keep them on your site, send the form with JavaScript instead (see Submitting without leaving the page).
Option 2: Netlify Forms#
If your site is hosted on Netlify, you don't need a third-party service. Netlify finds forms in your HTML when it deploys and stores the submissions in your Netlify dashboard. At the time of writing, form submissions are listed as included on the free plan.
-
In your Netlify project, open Forms and select Enable form detection. Netlify only scans for forms from the next deploy onwards.
-
Give the form a unique
nameand adddata-netlify="true":html<form name="contact" method="POST" data-netlify="true"> -
Redeploy. The form appears under Forms in the dashboard.
-
To get emails, add a notification under Forms, Submission notifications.
Netlify's build only sees forms that are in the HTML files it deploys. If your form is rendered by JavaScript (common in React and Next.js), add a hidden input so Netlify knows which form a submission belongs to, and make sure a plain HTML copy of the form exists in a static file:
<input type="hidden" name="form-name" value="contact">The full details are in Netlify's forms setup guide.
Option 3: Basin#
Basin is another form backend that works on any host, with a more generous file upload allowance. At the time of writing, its free plan includes 50 submissions a month, one form endpoint, basic spam protection and 100 MB of file uploads.
-
Create a form in Basin and copy the endpoint from its Setup page, for example
https://usebasin.com/f/1a2b3c4d5e6f. -
Use it as the form's
action:html<form action="https://usebasin.com/f/1a2b3c4d5e6f" method="POST"> -
Submit a test and check Basin's dashboard and your email.
Basin also accepts JavaScript submissions; set the Accept header to application/json to get a JSON
response, as shown below.
Which one should you use?#
| Formspree | Netlify Forms | Basin | |
|---|---|---|---|
| Works on any host | Yes | Netlify only | Yes |
| Free plan | 50 submissions/month | Included in Free plan | 50 submissions/month, 1 form |
| Setup | Change action |
Add an attribute | Change action |
Limits are at the time of writing; check the linked pricing pages before you rely on them.
If you host on Netlify, Netlify Forms is the obvious choice. On Cloudflare Pages, GitHub Pages or anywhere else, use Formspree or Basin. For several forms on a low-traffic site, Formspree's unlimited forms help; if visitors need to attach files, Basin's free plan allows uploads and Formspree's doesn't.
Submitting without leaving the page#
A plain HTML submit sends the visitor to the service's page or reloads yours. Sending the form with
fetch keeps them on the page so you can show your own message. This works with Formspree and Basin:
<form id="contact" action="https://formspree.io/f/abcdwxyz" method="POST">
<!-- fields as above -->
<p id="form-status" role="status"></p>
</form>
<script>
const form = document.getElementById("contact");
const status = document.getElementById("form-status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button[type=submit]");
button.disabled = true;
try {
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error();
form.reset();
status.textContent = "Thanks, your message has been sent. We'll reply within one working day.";
} catch {
status.textContent = "Sorry, something went wrong. Please email us at hello@example.com.";
} finally {
button.disabled = false;
}
});
</script>For Netlify Forms, post to your own site instead, URL-encoded rather than as JSON:
await fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(new FormData(form)).toString(),
});role="status" makes screen readers announce the message. Always offer a fallback contact method in the
error message, so a failed request doesn't lose you an enquiry.
Keep spam out#
A public form will get spam within days. Layer these, starting with the cheapest:
1. A honeypot field#
A honeypot is a field that humans never see but bots fill in. Any submission with a value in it is discarded. It's invisible to real visitors, unlike a CAPTCHA.
Formspree and Basin both recognise a field named _gotcha:
<div class="hidden" aria-hidden="true">
<label for="gotcha">Leave this field empty</label>
<input id="gotcha" type="text" name="_gotcha" tabindex="-1" autocomplete="off">
</div>Netlify lets you name the field yourself with the netlify-honeypot attribute:
<form name="contact" method="POST" data-netlify="true" netlify-honeypot="bot-field">
<p class="hidden">
<label>Don't fill this out if you're human: <input name="bot-field"></label>
</p>
<!-- your fields -->
</form>Hide the honeypot with a CSS class (as Formspree recommends) rather than an inline style, so it doesn't look
like an obvious trap in the markup. tabindex="-1" and aria-hidden keep keyboard and screen reader users out of it.
2. The provider's own filtering#
All three services filter spam on the server. Netlify runs every submission through Akismet and puts suspected spam in a separate list you can review. Formspree and Basin include basic spam filtering on their free plans, with stronger filters on paid plans. Check the spam folder in the dashboard now and then for real enquiries caught by mistake.
3. A CAPTCHA, if you still need one#
If spam gets through, add a challenge. Netlify supports reCAPTCHA with data-netlify-recaptcha="true"
(Netlify spam filters), and Basin supports
Cloudflare Turnstile, reCAPTCHA and hCaptcha. Turnstile is usually the least annoying for visitors, as most
people never see a puzzle.
Test it properly#
Before you launch, submit the form from the live site, not from your computer, and check:
- The email arrives, including in your spam or promotions folder. Mark it as "not spam" so future enquiries land in your inbox.
- Reply-to works. Press reply and check it addresses the visitor, not the service.
- Every field comes through, including checkboxes and drop-downs. A missing
nameis the usual cause of a missing value. - Errors are handled. Submit an empty form and an invalid email. Then turn off your Wi-Fi and submit, to see what visitors see when the request fails.
- It works on a phone, with the on-screen keyboard showing the right layout for email and phone fields.
- The honeypot doesn't block you. Your own test submission should arrive; a browser autofill can sometimes fill a badly hidden honeypot.
Put a reminder in your calendar to send a test once a month. Forms break quietly when a plan runs out or someone changes an email address.
Option 4: A serverless function#
If you'd rather not use a form service, most static hosts run small functions alongside your files:
Netlify Functions, Vercel Functions and Cloudflare Pages Functions. The form posts to something like
/api/contact, the function checks the fields and sends an email through an email API such as Resend or
Postmark.
This gives you full control over where data goes and how emails look, but you write and maintain the validation, spam protection and error handling yourself, and keep API keys in the host's environment variables, never in browser code. The forms docs show a Next.js Route Handler and a Resend example you can adapt.
For most small sites, a form service is the better trade: less code, built-in spam filtering and a dashboard of every submission.
FAQ#
Can I use a mailto: link instead?#
You can add one as well, but not instead. A mailto: link only works if the visitor has an email app set up,
which many people on shared or work computers don't. A form works for everyone.
Is it safe to put the form endpoint in my HTML?#
Yes. The endpoint is meant to be public: it only accepts submissions. Spam filtering and, on paid plans, restricting the form to your domain stop it being abused.
Do I need a privacy notice for a contact form?#
If you collect personal data such as names and emails, privacy law in many places (including the UK GDPR) expects you to say what you collect, why and who processes it. Mention your form provider in your privacy policy and link to it near the form.
Next steps#
Templates such as Counsel, Keystone and Clinic include validated contact, quote and booking forms ready to connect. The forms docs show exactly where to plug in Formspree or Netlify Forms in each template, and how to host a static website for free helps you pick a host.