GitHub Pages will host your site for free, forever, straight off a branch. What it will not do is run any code when a visitor submits a form. There is no PHP, no Node process, no serverless function. It serves files.

So a plain <form> has nowhere to send anything, and every "add a contact form to GitHub Pages" answer comes down to pointing it at something that is not GitHub Pages.

This walks through the version that takes about two minutes and keeps working.

The markup

Replace the action with your own endpoint and this is a functioning contact form:

<form action="https://formjetty.com/f/YOUR_FORM_ID" method="post">
  <label for="email">Your email</label>
  <input id="email" name="email" type="email" required>

  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>

  <button type="submit">Send</button>
</form>

No JavaScript, no build plugin, no Jekyll gem. The browser posts the form directly, the endpoint stores it and emails you, and the visitor lands on a hosted thank-you page.

To get an endpoint: create a free FormJetty account, name your form, and copy the URL from its Installation tab. The free plan is 500 submissions a month.

In Jekyll

GitHub Pages builds Jekyll sites natively, so put the form in an include and reuse it:

{% raw %}<!-- _includes/contact-form.html -->
<form action="{{ site.form_endpoint }}" method="post">
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>{% endraw %}

Then set form_endpoint in _config.yml and drop {% raw %}{% include contact-form.html %}{% endraw %} into any page. Changing the endpoint later is one line.

Staying on the page

If you would rather not navigate away, post it with fetch and ask for JSON back:

const form = document.querySelector('form');

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const response = await fetch(form.action, {
    method: 'POST',
    headers: { Accept: 'application/json' },
    body: new FormData(form),
  });
  const result = await response.json();
  status.textContent = result.ok ? 'Thanks, we got it.' : result.error.message;
});

The endpoint returns a predictable JSON shape either way, with a stable error code you can branch on rather than a string you have to pattern match.

FormJetty also ships a small helper script that does the loading state, per-field errors and aria-invalid for you, if you would rather not write that by hand.

Keeping the spam out

A public form endpoint on a public repo gets found. Three things worth doing on day one:

Restrict the origin. Set the allowed domain to yourname.github.io (or your custom domain) so the endpoint rejects posts from anywhere else. This alone stops most drive-by abuse.

Leave the honeypot on. A hidden field that humans never fill and bots usually do. It costs nothing and catches a surprising amount.

Turn on reCAPTCHA v3 if the volume warrants it. Not by default: it is a dependency on Google and a bit of extra markup. Add it when the honeypot and scoring stop being enough.

Anything scored as spam is held in a separate inbox rather than deleted, so a false positive is recoverable, and held spam does not count against your monthly limit.

Do not commit your endpoint carelessly

Your form ID is not a secret in the sense that a password is, and it has to be in the page for the form to work. But it is worth knowing what it is: an opaque random string that reveals nothing about your account or your email address. If it ever gets abused, rotate it from the form's settings and update the one line in your template.

Never put an API key or a mail provider credential in a GitHub Pages repo. Everything in that repo is public, including the history.

What you end up with

A static site on free hosting, a form that posts to a real backend, submissions stored and searchable, spam held for review, and an email in your inbox when someone gets in touch. No server, no cron job, nothing to patch.

Create an endpoint and paste it into your template.