Skip to content

Guide

Add a contact form to your Astro site

Astro's whole promise is shipping less JavaScript. Your contact form shouldn't be the exception.

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

Create the Form before you have an account

Start with one command and no signup. You get a real Form — an id and a submit URL that will survive into production — plus 24 hours and five test messages to prove it works. Keep the token it prints; it appears once.

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 component

No frontmatter, no client directive, no island. Because there's nothing to hydrate, Astro renders this to pure HTML and ships not one byte of JavaScript for it — a form that would make a Lighthouse audit smile.

Drop it into any page with an import and a tag. It keeps working if you later turn on view transitions, because a native form post doesn't care about client routing.

src/components/ContactForm.astro
<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 that wants it
---
import ContactForm from "../components/ContactForm.astro"
---
<ContactForm />

Send one and watch it land

Run the dev server, fill the form in, press Send. Or test from the terminal — either way, ask the sandbox what it holds and you'll find your message stored, timestamped, waiting.

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. Every new message is saved first, then sent — this site you're reading is an Astro site, its contact form is a Postbag form, and this is exactly how it's wired. Of course it is.

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 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.
Dev server first, origin second
Astro's dev server lives at http://localhost:4321 by default. If you created the sandbox with your production domain, browser posts from dev will be refused — create it with the localhost origin while you build.
The thanks page
A static form post ends on a small hosted thanks page. Prefer your own? Add a hidden input named _redirect with the address, or add a client-side fetch later — same URL either way.
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.

Or clone the working example.

Prefer a folder you can open and run? This Astro example is the same form, already a component, ready to try locally.

Clone this working example

Questions, answered

Do I need a client:load directive?
No. There is nothing to hydrate. The component is static HTML, which is rather the point of Astro.
Does this work with SSR adapters?
Yes. The form posts from the browser straight to Postbag, so it behaves the same whether the page was prerendered or server-rendered.
Can I show an inline thank-you instead of redirecting?
Yes — add a small script that posts the same fields as JSON to the same URL and swaps in a confirmation. The static version keeps working for everyone else.
What about spam?
The hidden _gotcha input catches naive bots, and anything suspicious is kept and labelled rather than binned. You can review it whenever you like.

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.