Guide
A free contact form for Hugo, Astro, and Eleventy sites
Hugo, Astro, Eleventy, and Jekyll all produce the same thing in the end: a folder of HTML files. That’s why they’re fast and cheap to host, and it’s also why a contact form needs help. A <form> has to POST somewhere, and a folder of files can’t receive anything.
Here’s an approach that costs nothing to run, doesn’t depend on your static host, and works the same way in every generator: one small endpoint on the Cloudflare Workers free plan, plus a form partial you drop into your templates.
The free options, briefly
- Your host’s built-in forms. Netlify Forms is free and unlimited on its current plans, but only if the site is hosted on Netlify. GitHub Pages, Cloudflare Pages, and most others have nothing built in.
- A hosted form backend’s free tier. These usually allow about 50 to 500 submissions a month and often add branding or limit features. Fine for a personal site with low traffic.
mailto:links. Free and simple, but they depend on the visitor having a configured mail app, and many don’t. They’re also easy targets for scrapers.- Your own Worker. Free up to 100,000 requests a day, works with any host, and you keep the data. It takes some setup.
The rest of this guide uses the last option. The endpoint itself is covered step by step in the Turnstile contact form tutorial. Build that first. This article is about wiring it into your generator cleanly.
One form, all generators
Write the form markup once. Everything below is plain HTML, so it works in any template language:
<form class="contact" method="post" action="https://contact-form.YOUR-SUBDOMAIN.workers.dev/">
<input type="hidden" name="_started" value="">
<div style="position:absolute;left:-10000px" aria-hidden="true">
<input name="website" tabindex="-1" autocomplete="off">
</div>
<label>Name <input name="name" required maxlength="80" autocomplete="name"></label>
<label>Email <input name="email" type="email" required maxlength="254" autocomplete="email"></label>
<label>Message <textarea name="message" required minlength="10" maxlength="5000"></textarea></label>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
<p class="form-status" role="status" aria-live="polite"></p>
</form>The hidden _started field records when the page loaded, so the Worker can reject submissions that arrive impossibly fast. The form-status paragraph is where the script below shows its messages. Screen readers announce it because of aria-live.
Hugo
Put the endpoint and site key in your config so staging and production can use different values:
# hugo.toml [params] formEndpoint = "https://contact-form.YOUR-SUBDOMAIN.workers.dev/" turnstileSiteKey = "YOUR_SITE_KEY"
{{/* layouts/partials/contact-form.html */}}
<form class="contact" method="post" action="{{ site.Params.formEndpoint }}">
... the fields above ...
<div class="cf-turnstile" data-sitekey="{{ site.Params.turnstileSiteKey }}"></div>
<button type="submit">Send</button>
<p class="form-status" role="status" aria-live="polite"></p>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{{ $js := resources.Get "js/contact.js" | minify | fingerprint }}
<script src="{{ $js.RelPermalink }}" defer></script>Then {{ partial "contact-form.html" . }} wherever you need it. Hugo Pipes minifies and fingerprints assets/js/contact.js for you.
Astro
Use PUBLIC_ environment variables, since both values end up in the browser anyway. Neither one is a secret. The Turnstile secret key stays in the Worker.
---
// src/components/ContactForm.astro
const endpoint = import.meta.env.PUBLIC_FORM_ENDPOINT;
const siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
---
<form class="contact" method="post" action={endpoint}>
<input type="hidden" name="_started" value="" />
<div style="position:absolute;left:-10000px" aria-hidden="true">
<input name="website" tabindex="-1" autocomplete="off" />
</div>
<label>Name <input name="name" required maxlength="80" /></label>
<label>Email <input name="email" type="email" required maxlength="254" /></label>
<label>Message <textarea name="message" required minlength="10" maxlength="5000"></textarea></label>
<div class="cf-turnstile" data-sitekey={siteKey}></div>
<button type="submit">Send</button>
<p class="form-status" role="status" aria-live="polite"></p>
</form>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script src="../scripts/contact.js"></script>Astro bundles a plain <script src> that points to a local file. The Turnstile script needs is:inline so Astro loads it as-is from Cloudflare.
Eleventy (and Jekyll)
{# _includes/contact-form.njk #}
<form class="contact" method="post" action="{{ site.formEndpoint }}">
... the fields above, with data-sitekey="{{ site.turnstileSiteKey }}" ...
</form>
// _data/site.json
{ "formEndpoint": "https://contact-form.YOUR-SUBDOMAIN.workers.dev/",
"turnstileSiteKey": "YOUR_SITE_KEY" }Jekyll works the same way: put the values in _config.yml and the form in _includes/.
Submitting without leaving the page
A full-page POST and a redirect to /thanks/ is perfectly fine and works with JavaScript turned off. If you’d rather show the result inline, this script enhances any form.contact on the page:
// contact.js: progressive enhancement. Without JS, the form still posts normally.
for (const form of document.querySelectorAll("form.contact")) {
form.querySelector('[name="_started"]').value = String(Date.now());
const status = form.querySelector(".form-status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
button.disabled = true;
status.textContent = "Sending…";
try {
const res = await fetch(form.action, {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Something went wrong.");
form.reset();
status.textContent = "Thanks! Your message was sent.";
} catch (err) {
status.textContent = err.message + " Please try again.";
if (window.turnstile) window.turnstile.reset(form.querySelector(".cf-turnstile"));
} finally {
button.disabled = false;
}
});
}On failure it resets the Turnstile widget, because each token can only be used once, and a retry with the old token would fail every time.
Making the Worker answer both ways
The script sends Accept: application/json. The Worker can check that header and return JSON to the script and a redirect to everyone else. Since the site and the Worker are on different origins, the JSON response also needs a CORS header. A small helper covers both, plus the timing check:
function reply(request, env, status, payload) {
const wantsJson = (request.headers.get("Accept") || "").includes("application/json");
const headers = {
"Access-Control-Allow-Origin": env.ALLOWED_ORIGIN,
"Vary": "Origin",
};
if (wantsJson) {
return Response.json(payload, { status, headers });
}
if (status < 400) {
return new Response(null, { status: 303, headers: { ...headers, Location: env.THANKS_URL } });
}
return new Response(payload.error, { status, headers });
}
// In your handler, after the Turnstile check:
const started = Number(form.get("_started"));
if (started && Date.now() - started < 2000) {
return reply(request, env, 400, { error: "That was quick. Please wait a moment." });
}A multipart FormData POST without custom headers counts as a “simple” request, so the browser sends no preflight. If you add headers such as Content-Type: application/json, you’ll also need to handle OPTIONS.
Before you call it done
- Test with JavaScript turned off. The form should still post and redirect.
- Test a submission that fails validation and check that the message makes sense.
- Add
localhostto the Turnstile widget’s hostnames for local dev, or use Cloudflare’s test keys. - Make sure someone gets notified when a message arrives. A form that stores messages nobody reads is about as useful as no form.
The ready-made version
Formharbor is this setup, finished: one Worker that serves contact, waitlist, and file upload forms to any number of Hugo, Astro, or Eleventy sites. It replies with JSON or redirects, includes rate limits, honeypot, and timing checks, sends email and webhook alerts, and comes with a dashboard and CSV export. The HTML snippets drop into any template. It costs $19 once and runs on your own Cloudflare free tier.