Guide
Add a contact form to a Jekyll site on GitHub Pages
GitHub Pages will build a Jekyll site and serve the HTML. It will not receive a form POST. There is no PHP, no Pages Functions, and no equivalent of Netlify Forms. That is why so many Jekyll contact pages still point at Formspree.
You can keep the site on GitHub Pages and still own the inbox. The form posts to a Cloudflare Worker on the free plan. The Jekyll side is one include and two config values. Build the Worker first with the Turnstile tutorial; this article is how to hook it up cleanly in Jekyll.
Put the public values in _config.yml
Neither of these is a secret. The Turnstile secret key stays in the Worker.
# _config.yml title: Example url: "https://example.com" baseurl: "" # "/repo-name" for a project site on username.github.io/repo-name form_endpoint: "https://contact-form.YOUR-SUBDOMAIN.workers.dev/" turnstile_site_key: "YOUR_SITE_KEY"
For a project site, baseurl is /repo-name (no trailing slash). Getting this wrong is the usual reason a thanks page or stylesheet 404s after you deploy.
A reusable include
# _includes/contact-form.html
<form class="contact" method="post" action="{{ site.form_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" 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="{{ site.turnstile_site_key }}"></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>
Drop it into any layout or page with {% include contact-form.html %}. Because the markup is plain HTML, it still works if a visitor has JavaScript turned off: the browser will POST to the Worker and follow a 303 to your thanks page.
A thanks page that respects baseurl
Add thanks.md at the site root:
--- layout: page title: Thanks permalink: /thanks/ --- Thanks — your message is on its way. I will get back to you as soon as I can.
Set the Worker’s THANKS_URL to the real public URL, including the baseurl if you have one: https://username.github.io/repo-name/thanks/ or https://example.com/thanks/. A missing subdirectory is a 404 after a successful send, which looks like a broken form.
Same-page status, if you want it
A small script can enhance the include so errors show inline. Put it at assets/js/contact.js and link it from the include or your footer. Without JavaScript, the form still posts normally.
// assets/js/contact.js
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;
}
});
}
The Worker has to answer with JSON when it sees Accept: application/json, and it has to send a CORS header, because GitHub Pages and the Worker are different origins. The Hugo / Astro / Eleventy guide has a reply() helper that does both. A multipart FormData POST without a custom content type is a “simple” request, so you usually will not see a preflight.
In your layout, reference the script with the same baseurl you already use for CSS:
<script src="{{ site.baseurl }}/assets/js/contact.js" defer></script>
Origins, hostnames, and project sites
The Worker’s ALLOWED_ORIGIN must match the browser Origin header exactly. Origin is scheme + host + port — it does not include the repo path. A project site at https://username.github.io/repo-name/ still has the origin https://username.github.io.
- Add that origin (or your custom domain) to
ALLOWED_ORIGIN. - If you serve both
www.example.comandexample.com, allow both, or pick one and redirect the other. - Add every hostname you actually use — including
localhostforbundle exec jekyll serve— to the Turnstile widget. Dummy keys from Cloudflare’s testing docs are fine locally; do not ship them.
What GitHub Pages will not do for you
A GitHub Action can rebuild the site. It cannot receive the form POST. Putting a secret in the Jekyll repo and hoping a workflow will mail you is the wrong shape. The secret belongs in the Worker, and the Worker is what you deploy with Wrangler, once, separately from the site.
If you later move the same Jekyll output to Cloudflare Pages, the form keeps working: the include still points at the Worker. Or you can switch the action to a same-origin Pages Function and drop CORS. Either way, you are not starting over.
The ready-made version
Formharbor is that Worker, finished: contact, waitlist, and file upload endpoints you can point a Jekyll include at, plus email alerts and a dashboard so you are not living in the Wrangler CLI. It costs $19 once and runs on your Cloudflare free tier. The HTML snippets drop into _includes/ with no change to how you deploy GitHub Pages.
More guides
- Add a contact form to Cloudflare Pages
- Turnstile contact form tutorial
- Contact form with Pages Functions
- Send email from a Worker contact form
- A free Formspree alternative
- Netlify Forms alternative on Cloudflare
- Free contact form for Hugo, Astro, and Eleventy
- Jekyll contact form on GitHub Pages
- Formspree vs running forms yourself
- Waitlist with referral codes
- File uploads from a static site to R2