Why GitHub Pages can't process forms (and why that's fine)
GitHub Pages is static hosting in the strictest sense. Every file in your repo gets served as-is from a CDN. There is no PHP, no Node, no Python, no Ruby runtime executing requests. The only server-side processing GitHub Pages does is the Jekyll build itself — and that runs once at deploy time, never per-request. By the time a visitor lands on your page, the HTML is already frozen.
The Jekyll plugin allowlist makes this even more restrictive. GitHub Pages only allows a curated set of jekyll-* plugins (sitemap, feed, redirect-from, a few SEO helpers) and explicitly disallows anything that could process requests or send mail. People try to install jekyll-contact-form, jekyll-mail, or roll their own Sinatra-based handler — none of it runs. The build either fails on push or silently skips the plugin.
Most people don't realize this until they push their site and watch a contact form do nothing. The good news: you don't need a backend, a serverless function, or a separate VPS. You just need a form endpoint that lives somewhere else and accepts the POST. That's what splitforms is. The HTML lives in your repo; the submission goes to a service that emails you. Two minutes of work, zero infrastructure.
How splitforms solves GitHub Pages forms
splitforms is a form-to-email backend designed for static sites. You sign up, get an access key, and any HTML form on the internet can POST to splitforms.com/api/submit with that key. The service validates the submission, runs it through AI spam detection, and emails you the contents. There's no JavaScript SDK to install, no library to bundle, no build step to add. The whole integration is HTML attributes.
That matches what GitHub Pages allows: HTML, CSS, and pure client-side assets. The browser does the work of submitting the form; splitforms does the work of processing it. GitHub Pages just keeps serving your repo's files like it always has.
The free tier covers 1,000 submissions per month, which is more headroom than most personal GitHub Pages sites will ever use. Webhooks, AI spam filtering, file uploads, and custom redirect URLs are all included for free — none of it is paywalled into a higher plan like Formspree or Getform do. If you eventually outgrow it, Pro is $5/month for 5,000 submissions, and there's a $59 plan that covers four years for sites that just need to keep running cheaply.
Step 1: Get a splitforms access key (1 minute)
Sign up at splitforms.com/login. There's no credit card, no plan to pick, no email verification dance. Enter your email, paste the 6-digit code, and you land in the dashboard with a pre-generated access key ready to copy.
- Go to splitforms.com/login
- Enter your email address
- Paste the 6-digit code from your inbox
- Copy the access key shown on the dashboard home (it looks like
sf_live_abc123...)
The access key is what authenticates submissions as yours. Treat it as a public identifier (it's safe to embed in HTML — there's no secret-key risk like Stripe's sk_ keys), but if you want to restrict it to one domain, configure Allowed Domains in the dashboard so submissions from anywhere else get rejected.
Step 2: Paste the form HTML into your site
Open the page where you want the contact form. On a plain HTML GitHub Pages site, that's usually index.html or contact.html at the repo root. On a Jekyll site, that's either a layout in _layouts/, a partial in _includes/contact.html, or directly in a page's front-matter block.
Paste this snippet wherever the form should appear:
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
<button type="submit">Send</button>
</form>Replace YOUR_ACCESS_KEY with the key you copied. That's the whole integration. Commit, push, and once GitHub Pages rebuilds (usually under 60 seconds), the form is live and submitting to splitforms.
For Jekyll users: use _includes for reuse
If you want the form on multiple pages (contact, footer, about), save it once as _includes/contact.html and pull it in with Liquid:
{% include contact.html %}This keeps the access key in one place. If you ever rotate keys, you edit one file instead of twelve. For a deeper Jekyll-specific walkthrough including front-matter and layouts, see the Jekyll contact form tutorial.
Jekyll vs plain HTML pages: where the snippet goes
GitHub Pages happily serves two very different kinds of repos. Pick the row that matches yours:
| Site type | Where to paste the form | Notes |
|---|---|---|
| Plain HTML repo | index.html or contact.html | Just edit the file. No build step. |
| Jekyll default theme | _includes/contact.html + {% include %} | Liquid renders it into every page that calls it. |
| Jekyll custom layout | _layouts/contact.html | Set layout: contact in the page's front matter. |
| Hugo via gh-pages | layouts/partials/contact.html | See Hugo tutorial. |
| Eleventy via gh-pages | _includes/contact.njk | See Eleventy tutorial. |
| Astro on GitHub Pages | A .astro component | The action attribute is unchanged. Build, push to gh-pages. |
Whatever the source format, the rendered output that lands in the browser is the same HTML. splitforms doesn't care what generated it. Framework-specific shortcuts (e.g. an Astro form backend or Svelte form backend snippet) are convenience, not requirement.
Custom styling (CSS only, no JS required)
Because the form is plain HTML, every CSS technique you already use works. Tailwind classes, BEM, vanilla CSS, your Jekyll theme's existing form styles — all of them apply. There's no shadow DOM, no iframe, no scoped styles to fight. Here's a minimal style example you can drop into your site's stylesheet:
form { display: grid; gap: 12px; max-width: 480px; }
form input, form textarea {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font: inherit;
}
form textarea { min-height: 140px; resize: vertical; }
form button {
height: 44px;
background: #111;
color: #fff;
border: 0;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
}If you want a starting template that already looks good, the free contact form templates page has copy-paste markup and CSS for common layouts (centered card, two-column with a map, sidebar widget). Drop one into your GitHub Pages repo and change the access key.
Successful submission feedback
By default, splitforms returns a JSON response. To redirect to a thank-you page after submit (the friendlier UX), add a hidden redirect input:
<input type="hidden" name="redirect" value="https://yourdomain.com/thanks" />Create thanks.html at the repo root. That's it — no JavaScript, no fetch handler, no event listeners.
Using a custom domain on GitHub Pages
If your site lives at yourdomain.com instead of username.github.io, you've already set up the CNAME file and DNS. None of that touches the form. The form action is an absolute URL pointing at splitforms.com, so it works the same whether the visitor lands on the apex domain, the GitHub subdomain, or a preview link.
One thing to configure: open the splitforms dashboard, go to your form's settings, and add both domains to Allowed Domains:
yourdomain.com— your real domainwww.yourdomain.com— the www variant if you use itusername.github.io— the GitHub default (useful for testing)
If you only list yourdomain.com and someone hits the form on the github.io URL, the submission gets rejected with a 403. This is a feature for production, an annoyance during development — list all the origins you actually use.
Email delivery on a custom domain
splitforms sends notifications from a verified address by default. If you want them to come from your custom domain (better deliverability and brand consistency), configure SMTP in the dashboard. Bring your own Gmail App Password, AWS SES, Postmark, or any SMTP server, and notifications send through that. If your emails are landing in spam after switching, see why contact form emails go to spam for the SPF/DKIM checklist.
Deploying via the gh-pages branch
Many static site frameworks (Hugo, Eleventy, Astro, Vite, plain webpack builds) build locally and push the output to a gh-pages branch that GitHub Pages serves directly. The flow is usually:
- Source files live on
main - You edit your form template (in
_includes/,layouts/partials/, or wherever) - Run your build (
hugo,npx eleventy,npm run build, etc.) - The build output (usually
public/ordist/) gets pushed togh-pages - GitHub Pages picks up the change and serves the new HTML within ~60 seconds
The thing to watch: edit the source template, not the gh-pages branch. If you edit gh-pages directly, the next build overwrites your change and your form disappears. CI scripts (GitHub Actions deploying to gh-pages) make this mistake costlier because the deploy happens automatically — one bad commit and the rollback is a build away.
A typical GitHub Actions deploy step looks like this:
# .github/workflows/deploy.yml
- name: Build site
run: hugo --minify
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./publicYour form snippet lives in the Hugo source, gets baked into the built HTML during the hugo --minify step, and ships to gh-pages along with the rest. No special handling required.
Spam protection without adding JS or CAPTCHAs
GitHub Pages sites tend to attract a particular flavor of spam: link-builders crawling github.io for contact forms, LLM-generated outreach pitches, and crypto airdrop scams. CAPTCHAs hurt conversion and feel out of place on a personal site. splitforms ships two defenses that need no JavaScript:
- Honeypot (
botcheck): a hidden checkbox most bots tick because they fill every field. If it's checked, the submission is silently dropped. - AI spam classifier: a language model reads the message body and flags anything that pattern-matches generic SEO outreach, broken-English crypto pitches, or templated LLM spam.
Both run server-side. There's nothing to install, no API key for a CAPTCHA provider, no extra <script> tag breaking your Content Security Policy. If you want the deep dive on what works and what's defeated by current LLM bots, see honeypot vs reCAPTCHA and the form spam protection complete guide.
Troubleshooting: CSP, baseurl, Cloudflare cache
If you push the form and it doesn't work, the failure is almost always one of these:
- Content Security Policy blocking the POST. If your
<meta http-equiv="Content-Security-Policy">tag includes a strictform-actionorconnect-srcdirective, browsers will block the submission to splitforms.com. Addhttps://splitforms.comtoform-action(the directive that governs form submissions). Test in Chrome DevTools → Console — CSP violations log there with the exact directive that blocked the request. - Jekyll baseurl breaking the redirect. Project pages live at
username.github.io/repo, and Jekyll'sbaseurlsetting prefixes internal links. The form action is absolute (https://splitforms.com/...) so it's unaffected, but if yourredirectvalue is a relative path like/thanks, the post-submit redirect will 404. Either use an absolute URL inredirect, or use Liquid:{{ site.url }}{{ site.baseurl }}/thanks. - Cloudflare cache serving the pre-update HTML. If you proxy GitHub Pages through Cloudflare, the edge cache may hold the old page for hours. Purge the URL in Cloudflare dashboard → Caching → Custom Purge. Test in incognito to bypass your browser cache.
- 403 / Allowed Domains rejection. The submission posts but you get a permission error. Check Allowed Domains in your splitforms dashboard — list both the apex domain and the github.io fallback so test submissions don't fail.
- Email not arriving. Check spam first. Then check the dashboard at splitforms.com/dashboard/submissions: if the submission is there but no email came through, it's a deliverability problem, not a form problem. The contact form not working guide walks through every step.
- Form HTML rendered with quotes escaped. Some Jekyll themes wrap raw HTML in
{% raw %}blocks. If yours escapes the form into literal angle brackets on the page, wrap the snippet in{% raw %}...{% endraw %}tags.
Next steps
- Grab the free key: splitforms.com/login.
- Pre-styled markup: free HTML contact form templates.
- Compare alternatives: best free form backend services 2026.
- Static-site umbrella guide: add a contact form to any static site.
- Switching from another service: migrate from Formspree.
- API contract, webhook envelope, and rate limits: /docs and /api-reference.
- Plan, security, and GDPR questions: /faq. Or browse all posts.
FAQ
Can GitHub Pages run a contact form on its own?
No. GitHub Pages is pure static hosting — it serves HTML, CSS, JS, and assets only. There is no PHP, no Node runtime, no serverless function support, and the Jekyll plugin allowlist excludes anything that could read POST bodies or send mail. The only way to make a form actually deliver email is to point the form's action attribute at an external endpoint that accepts the submission, processes it, and emails you. splitforms is that endpoint.
Does this work with both Jekyll and plain HTML GitHub Pages sites?
Yes. The form is just HTML — it doesn't care whether the surrounding page is generated by Jekyll, by a different static site generator like Hugo or Eleventy, or hand-written into index.html. Paste the same snippet into _includes/contact.html, into a Jekyll layout, or directly into a plain HTML file. The browser submits it to splitforms either way and you get the email.
Will my custom domain on GitHub Pages affect form submissions?
No. Custom domains (yourdomain.com mapped via CNAME) work identically to the default username.github.io URL. The form action points at splitforms.com, not at your domain, so DNS for your site doesn't matter for delivery. The only caveat: if you set Allowed Domains in your splitforms dashboard, list both yourdomain.com and username.github.io so test submissions from either origin succeed.
Do I need to deploy via the gh-pages branch?
Not specifically. GitHub Pages supports three sources: the root of the main branch, a /docs folder on main, or the gh-pages branch. The form HTML doesn't care which source you use — whichever branch GitHub serves becomes your live site. If you build a Hugo or Vite site locally and push the build output to gh-pages, paste the form snippet into the source template before building, not into the published branch.
Does the form work if my site is at username.github.io/repo (project pages)?
Yes. Project pages live at a subpath (the baseurl) instead of the root domain, which sometimes breaks asset links — but the form action is an absolute URL pointing at splitforms.com, so baseurl is irrelevant for submission. Just make sure any relative links inside the form's redirect target (thank-you page) account for the baseurl, otherwise the post-submit redirect 404s.
How do I stop spam without a backend?
splitforms includes a honeypot field (botcheck) and an AI spam classifier on every plan — no reCAPTCHA, no Turnstile, no extra JS. Drop the hidden botcheck input into the form and 90%+ of bot submissions are silently dropped before they hit your inbox. For the rest, the AI classifier reads the message content and quarantines anything that looks like SEO outreach, crypto pitches, or generic LLM-generated spam.
Why not just use mailto: links instead?
mailto: opens the visitor's mail client, which breaks on phones that have no default mail app configured, on shared kiosks, and on anyone using webmail without a configured handler. Conversion drops by 40–60% versus a real form on most sites we've measured. A real form also gets you spam filtering, structured fields, reply-to handling, and the submission saved in a dashboard. mailto: gets you a clipboard frustration loop.
What if my Cloudflare cache keeps serving the old HTML after I update the form?
GitHub Pages plus Cloudflare is a common setup, and Cloudflare's edge cache can keep serving the pre-update HTML for hours after your push. Purge the page in the Cloudflare dashboard (Caching → Configuration → Custom Purge) and add a cache-busting query string to your asset references during development. Test in an incognito window so you don't hit your browser cache instead of Cloudflare's.