GitHub Pages contact form, no server required
GitHub Pages executes no server-side code of any kind, at any price, on any plan. A GitHub Pages contact form therefore works by posting to an endpoint hosted somewhere else:
<form action="https://api.formroute.dev/f/site_xyz" method="POST">
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<button>Send</button>
</form>
That markup goes straight into the committed file that becomes the page: contact.html at the repo root, a Jekyll layout, or a generator’s template. There is no .htaccess, no config entry and no repository setting involved, because there is nothing on the host to configure.
What GitHub Pages will not run
Most contact form tutorials assume at least one of these exists. On GitHub Pages, none of them do:
| You might reach for | On GitHub Pages |
|---|---|
A PHP script calling mail() |
Not executed. The file is served as a static asset, source and all |
| A Node or Python handler | No runtime |
| A serverless function | Not part of the product |
| Server-side includes | Not processed |
| A redirect with a real status code | Not configurable |
That last constraint costs you the post-submit redirect: with no way to configure a server-side redirect, a thank-you page has to be a real page in your build that the endpoint sends people to.
The published limits are worth knowing before you plan around the platform. GitHub documents a 1 GB ceiling on published site size, a soft 100 GB per month bandwidth limit, a soft limit of 10 builds per hour, and a 10 minute timeout on deployments. A form endpoint sits outside all four, since submissions never touch your Pages site.
Actions builds the site, it does not host a server
This is the misconception worth clearing up, because it sounds like a loophole. A GitHub Actions workflow can run any code you like: install dependencies, call APIs, generate pages. All of it runs at build time and finishes before a visitor arrives.
A form submission happens minutes or months later, when nothing in your workflow is running. Actions gives you a build server, not a web server, and the two are not interchangeable.
The form is the same whatever built the site
GitHub Pages is a host, not a generator. It ships Jekyll by default and will serve output from anything else you build. The markup does not change; only its home does.
| What builds your site | Where the form markup goes |
|---|---|
| Jekyll, built by GitHub | _includes/contact-form.html, pulled in the way the Jekyll contact form does |
| Jekyll, built by you in Actions | The same, with no restriction on plugins |
| Hugo or Astro, built in Actions | The Hugo contact form partial or the Astro contact form component |
| Hand-written HTML | The page itself |
Two Jekyll defaults that bite non-Jekyll sites
When you publish from a branch rather than from an Actions workflow, GitHub runs Jekyll over whatever you committed. Two things follow, and both look like your site is broken:
- Files and folders starting with an underscore are dropped. Jekyll treats them as source, not output. This deletes
_astro/on an Astro build and the equivalent on Sphinx, Antora or a Next.js export. The fix is a.nojekyllfile at the root of the publishing source, which turns the Jekyll step off entirely. - Jekyll runs in safe mode with a fixed plugin list. A site that depends on a gem outside that list builds locally and fails on GitHub. The escape hatch is to build it yourself in Actions and publish the output, which lifts the restriction because GitHub is no longer the one running Jekyll.
Neither affects a contact form, which is plain markup. Both affect the site the form lives in, which is why they belong on the same checklist.
Your endpoint URL sits in a public repo, and that is fine
On a public repo, anything in the markup is readable by anyone. That is the correct reason never to put a secret API key in a form, and the wrong reason to avoid a form endpoint.
A form endpoint is public by design. It is in the HTML of every page carrying the form, so a visitor reading source sees it whether your repo is public or not. What protects it is not obscurity: FormRoute runs Cloudflare Turnstile and an AI classifier before anything is stored or forwarded, and enforces your validation rules on every request, including requests that never rendered your page. What a form backend does covers that half in full.
The constraint is the feature
A site with no server has nothing to patch, nothing to keep running, and no runtime to pay for. That is why a static host plus an external endpoint outlives most of the stacks that replaced it. FormRoute is the one moving part, and access is private for now: request an invite.