Skip to content

Guide

Add a contact form to your Eleventy site

Eleventy stays out of your way. Its contact form should have the same manners.

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 Eleventy site and prove a test submission was stored.

Create the Form before you have an account

One command, no signup: a real Form id and submit URL inside a 24-hour sandbox that stores up to five test messages and sends nothing. The token prints once — save it before moving on.

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

One file in _includes/, one include tag wherever the form should appear. The .njk extension means Nunjucks renders it no matter what language the surrounding template speaks — and since the file is static markup with no front matter, there's nothing for the data cascade to even notice.

_includes/contact-form.njk
<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 template
{% include "contact-form.njk" %}

Send one and watch it land

Run npx @11ty/eleventy --serve, open the page, write yourself something nice. Or test from the terminal. Then read the sandbox back — stored, timestamped, safe.

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

Once claimed, connect your inbox and route the Form to it. Saved first, sent second, retried when needed — the boring reliability your minimal site deserves.

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

Quote the include name
Nunjucks wants {% include "contact-form.njk" %} with quotes. Liquid templates in the same project use their own unquoted syntax — check which engine the including file speaks.
The origin is part of the deal
A sandbox Form only accepts browser posts from the origin you gave at creation. Building locally? Create it with your dev address (say http://localhost:4321) and add your real domain after you claim. Terminal tests carry no origin, so curl always gets through.
The thanks page
A plain form post ends on a small hosted thanks page; a hidden _redirect input pointing at your own page changes that.
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 the includes directory have to be _includes?
That's Eleventy's default; if your config moves it, the include path follows your configuration, not this guide.
Can Markdown pages use the include?
Yes — Markdown files preprocessed by Nunjucks or Liquid can pull it in the same way.
Is there any JavaScript in this?
None. The form posts natively. If you later want an inline confirmation, a small fetch to the same URL does it without touching the include.
What happens to messages I don't like?
Delete them when you choose. Postbag never quietly bins a message on your behalf — spam included, which is labelled rather than lost.

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.