Skip to content

Guide

Add a contact form to your Hugo site

Your site builds in milliseconds. It shouldn't grow a server for one form.

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

Create the Form before you have an account

One command and no account gets you a real Form: an id, a submit URL, and a 24-hour sandbox that stores up to five test messages without sending a thing. The token in the response is shown once — treat it like a key, because it is one.

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 a partial

Hugo's partials are exactly the right shape for this: the form lives in one file under layouts/partials/, and any template can summon it with one line. It's plain HTML inside — Go templating has nothing to interpolate here, which means nothing to escape and nothing to break.

layouts/partials/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>
Wherever your contact page's layout wants it
{{ partial "contact-form.html" . }}

Send one and watch it land

Run hugo server, open the page, send yourself a message — or use the terminal. Then ask the sandbox what it holds and find it there, stored, with its arrival time.

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

After claiming, connect your inbox and route the Form to it. Messages are saved before they're sent — so the person who wrote to you at 2am is still there on Monday, even if your inbox wasn't.

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

The thanks page
A plain form post ends on a small hosted thanks page. To send visitors back to your own site, add a hidden input named _redirect with the address you want.
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.
hugo server's origin
Local Hugo lives at http://localhost:1313. If your sandbox was created with the production origin, browser posts from dev are refused — create it with the localhost origin while you build, and add the real domain after claiming.
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 slow down my build?
Not measurably. It's one static partial — Hugo renders it like any other markup, and there's no asset pipeline behind it.
Can I put the form in Markdown content?
Make it a shortcode wrapping the same markup, and it drops into any content file. Partials serve layouts; shortcodes serve content.
Do themes get in the way?
No. Your project's layouts/partials/ overrides or extends the theme's, so the form survives theme updates.
What happens to spam?
The hidden _gotcha input catches naive bots; anything caught is stored and labelled rather than deleted, so the record stays complete.

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.