Skip to content

Guide

Add a contact form to Jekyll and GitHub Pages

GitHub Pages runs no server code, ever. Your contact form was always going to need a friend.

Last updated

Or skip the reading entirely.

Everything below is agent-work. Paste this to yours, then come back to a working form — you show up at the end to say "keep it."

Paste to your agent
Install the Postbag skill with `npx skills add faahim/postbag --skill postbag`, then use it to add a working contact form to this Jekyll site and prove a test submission was stored.

Create the Form before you have an account

GitHub Pages can't run the receiving side, so borrow one: a single command returns a real Form id and submit URL inside a 24-hour sandbox — five test messages stored, nothing sent, no account yet. The token it prints appears once; keep it.

Create a Form before you have an account
sandbox_json="$(npx postbag --json sandbox create \
  --name "Contact" \
  --origin "https://example.com")"

submit_url="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox.submit_url')"
form_id="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox.id')"
sandbox_token="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox_token')"
claim_url="$(printf '%s\n' "$sandbox_json" | jq -er '.claim_url')"

printf 'Submit URL: %s\nForm ID: %s\nClaim URL: %s\nSandbox token: %s\nReplace YOUR_POSTBAG_SUBMIT_URL in the next snippet with the Submit URL above.\n' \
  "$submit_url" "$form_id" "$claim_url" "$sandbox_token"

Make it an include

Put the form in _includes/ and pull it into any page or layout with one Liquid tag. Liquid scans the file for its own syntax on the way through, finds none — this is plain HTML — and passes it along untouched.

_includes/contact-form.html
<form action="YOUR_POSTBAG_SUBMIT_URL" method="POST">
  <label>
    Email
    <input type="email" name="email" required />
  </label>
  <label>
    Message
    <textarea name="message" required></textarea>
  </label>
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-10000px" aria-hidden="true" />
  <button type="submit">Send</button>
</form>
Any page, layout or post
{% include contact-form.html %}

Send one and watch it land

Serve the site locally or push to Pages, then send yourself something — or test from the terminal. Ask the sandbox what it holds: the message is stored with its arrival time, no server of yours involved.

Send one test from your terminal
curl --fail --silent --show-error -X POST "$submit_url" \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]", "message": "hello from the terminal" }'
See it stored
POSTBAG_SANDBOX_TOKEN="$sandbox_token" npx postbag sandbox status

Claim it when you're ready

The creation response included a claim link. Open it, sign in — Google, GitHub, or an emailed code — and the sandbox becomes a real Form in your own workspace. Same id, same submit URL: the page you just wired needs no edit. Your test messages come along, still marked as tests.

Turn on email

Claim the Form, connect your inbox, route to it. From then on GitHub serves the page, Postbag keeps the messages, and you read them — a fair division of labour between three parties who are each good at one thing.

Connect your inbox, then route the Form to it
destination_json="$(curl --fail --silent --show-error -X POST https://postbag.dev/v1/destinations \
  -H "Authorization: Bearer pb_live_…" \
  -H "content-type: application/json" \
  -d '{ "type": "email", "config": { "to": ["[email protected]"] } }')"
destination_id="$(printf '%s\n' "$destination_json" | jq -er '.id')"
route_body="$(jq -n --arg form_id "$form_id" --arg destination_id "$destination_id" \
  '{ form_id: $form_id, destination_id: $destination_id }')"

curl --fail --silent --show-error -X POST https://postbag.dev/v1/routes \
  -H "Authorization: Bearer pb_live_…" \
  -H "content-type: application/json" \
  -d "$route_body"

The parts that bite

github.io is an origin too
If your site lives at username.github.io, that full address is the origin to register — with a custom domain, use that instead. The sandbox honours exactly the origin you gave it.
The thanks page
A plain form post ends on a small hosted thanks page. Add a hidden input named _redirect pointing at a page of your own site to bring visitors home instead.
No plugin required, deliberately
GitHub Pages runs Jekyll in safe mode with an allow-list of plugins. This integration is markup only, so safe mode has nothing to object to.
Five tests, then it wants a decision
A sandbox holds five test messages of up to 16 KiB each, for 24 hours, and sends nothing anywhere. That is the rehearsal budget. Claiming makes it permanent; letting it expire costs nothing.

Questions, answered

Does this work on GitHub Pages' free tier?
Yes. The form is static HTML, which is all Pages ever serves. The storing and sending happen on Postbag's side of the fence.
Can I use it in Markdown posts?
Yes — the include tag works in posts and pages alike, anywhere Liquid is processed.
What about the site.github build pipeline?
Nothing changes. No gems, no configuration, no Actions workflow — the build stays exactly as boring as it was.
Where do messages wait if my email is down?
In Postbag, stored and visible, while sending retries with patience. An outage delays a message; it doesn't lose one.

Give the form job to your agent.

It can build, wire and test the form before you even sign up. Claim it when it's worth keeping.