Blog ·
How to add a contact form to a static site
Add a working contact form to Hugo, Astro, Eleventy or plain HTML — no PHP or serverless. Markup, honeypot, spam limits, and FormHeron setup.
- static-site
- html
- forms
A static site is files on a CDN. There is no PHP, no long-running Node process, and nowhere safe to put an SMTP password. The correct way to add a contact form is to POST the browser form to a form backend that stores the lead, filters spam, and emails you.
This guide covers the markup, honeypot, host setup, and what happens after submit. It assumes you want something production-safe, not a mailto: link.
What you need
- An HTTPS endpoint that accepts POST (form-encoded or JSON)
- Spam controls so bots do not burn email budget or quota
- A place to review leads later — not only an inbox flood
- Optional webhooks for Slack, CRM, or automation tools
- A honeypot field that is off-screen (not type=hidden)
FormHeron provides those pieces. You keep the static site; we receive the POST. See the full feature list if you want the product boundary first.
The form markup
Use a normal HTML form. method must be POST. action is your form backend URL. Include a honeypot: a text input positioned off-screen with tabindex=-1 and autocomplete=off. Do not use type=hidden — password managers and autofill often fill hidden fields and create false spam positives.
<form action="https://formheron.com/f/YOUR_KEY" method="POST">
<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>
<!-- Honeypot: off-screen text field, not type=hidden -->
<input type="text" name="_hp" tabindex="-1" autocomplete="off"
aria-hidden="true" style="position:absolute;left:-9999px" />
<button type="submit">Send</button>
</form>Replace YOUR_KEY with the public key from the dashboard. Field names become keys in the stored payload. Keep the payload flat: string fields only. Nested objects are rejected (max body 100 KB, max 50 fields).
JSON vs form-encoded POST
A native form submit without JavaScript uses application/x-www-form-urlencoded. That is the simplest path and works with JavaScript disabled. If you intercept submit with fetch, you can send application/json with a flat object of string values and Accept: application/json to get machine-readable error codes.
Both work. JSON is better when you need inline success/error UI. Form-encoded is better when you want zero client script. Codes such as quota_exceeded, rate_limited, and origin_not_allowed are documented in the docs.
Which hosts this works on
Netlify, Vercel, Cloudflare Pages, GitHub Pages, S3 + CloudFront, any Apache or nginx static root. The host only serves HTML. FormHeron receives the POST. Platform-specific notes: Astro, Hugo, Eleventy, HTML.
After submit
Without JavaScript, the browser follows a 303 redirect to your thank-you URL (if configured and on an allowed origin) or FormHeron’s default thanks page. Redirect URLs are validated at save and again at submit so the endpoint cannot become an open redirect.
With fetch + JSON, you stay on the page and branch on res.ok and the code field. Spam-classified submissions still return success to the bot (so the honeypot is not taught) but never email you and never spend quota.
Spam on static sites
Public form URLs get scraped. Stack honeypot, atomic rate limits, optional Cloudflare Turnstile with your own keys, and a rule that spam never spends quota. Origin allowlists reduce casual cross-site posts from other websites; they are not authentication — curl can forge Origin. Details: how to stop contact form spam.
Checklist before launch
- Create a form and copy the public key
- Paste the snippet with _hp off-screen
- Add production (and preview) origins to the allowlist
- Set a redirect on your own domain if you want a branded thanks page
- Submit a test lead; confirm email and inbox row
- Optional: Turnstile keys, Slack, or webhooks on Indie+
Common mistakes on static hosts
- Using mailto: and calling it a form — broken on mobile and spam-prone
- Putting SMTP secrets in a serverless function without rate limits or honeypots
- Leaving the origin allowlist empty in production (fine for prototypes, risky later)
- Using type=hidden for the honeypot and wondering why real users are marked spam
- Enabling Netlify Forms and FormHeron on the same form (dual delivery chaos)
How does FormHeron fit a static workflow?
You create a project and form in the dashboard, copy the public key, and paste the snippet. Notification email goes to the address you set on the project. Non-spam leads open in the inbox with status pipeline (new → contacted → won → lost). On Free you get email + inbox; on Indie you add Slack and HMAC webhooks; on Pro you add CSV export and higher limits.
Retention is automatic: 30 days Free, 90 Indie, 365 Pro, then hard delete. Export and delete live under Dashboard → Data settings. That matters when a client asks how long leads are kept.
Progressive enhancement with fetch
If you want no full-page navigation, preventDefault and POST JSON. Always send _hp as an empty string for humans. Check res.ok; on failure read data.code. Keep the native form action as a fallback if progressive enhancement is a goal — or accept that JSON-only forms require JS.
Copy a ready basic HTML template, or start free and paste the dashboard snippet. Free plan: 250 submissions/month, 1 project, 2 forms, 30-day retention.
FormHeron is a form backend with spam controls, a lead inbox, HMAC webhooks and Slack. Free plan: 250 submissions/month. Leads stored in the EU (Amsterdam); operated from India. No raw IPs.