I moved a client's marketing site to Astro a few weeks ago. Astro 7 was the reason — the Rust compiler and stable route caching cut the build from ~4 minutes to about 90 seconds. Fast builds were the whole point.
Then I hit the one thing Astro can't do out of the box: accept a contact form submission.
Here's the honest version of what I tried, what worked, and the two gotchas that cost me an afternoon.
Why Astro forms trip people up
Astro is component-first and ships almost zero client-side JavaScript. Most "add a form" tutorials assume a Node runtime. Three things follow from that, and they're the reason this feels different from a Next.js or Rails form:
-
A form has to POST somewhere. The
actionattribute is just a URL. There's no "submit to Astro" without an adapter. -
Astro Actions need a server adapter. They're stable and genuinely type-safe, but they require
output: 'server'or'hybrid'plus@astrojs/vercel/netlify/cloudflare/node. On the defaultoutput: 'static'build, they don't run. -
No spam filtering is included. Neither Actions nor a plain
<form>ships with detection. That's on you.
What I tried (and why I backed out)
Astro Actions. Beautiful DX, end-to-end typed. But to handle one 3-field contact form I had to flip output: 'static' to 'server', add an adapter, manage secrets, and accept cold starts. My "static site" now had a server. That's a lot of surface area for a page that asks for a name, an email, and a message.
Netlify Forms. Five minutes, genuinely nice — but the site was on Vercel, so it was a non-starter. The lock-in matters more than the price tag.
Cloudflare Turnstile + Workers. Turnstile is a great CAPTCHA (free, accessible, no image grids). But to use it on Vercel I now maintain a Worker. I'd traded an adapter for a different server to maintain.
What actually shipped: a plain HTML form POSTing to an external endpoint. No adapter, no server, no secrets.
The form
This is the entire integration. src/components/ContactForm.astro:
<form
action="https://api.usesmartform.com/api/v1/f/YOUR_FORM_ID"
method="POST"
>
<label for="name">Name</label>
<input id="name" name="name" type="text" required minlength="2" />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{/* Honeypot — bots fill it, humans never see it */}
<input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off" />
{/* Where to send the browser after a successful submit */}
<input type="hidden" name="_next" value="https://yoursite.com/thanks" />
<button type="submit">Send Message</button>
</form>
Drop it in a page and you're done. Astro's whole pitch is zero JS by default, and a method="POST" form navigates to the endpoint and back — the service returns a 302 to your _next URL. No client-side state, no hydration cost, no framework.
It doesn't matter whether the surrounding page is an Astro component or a React/Vue/Svelte island. As long as the rendered DOM contains <form action="..." method="POST">, the submission lands.
Gotcha #1: the Accept header
If you want an inline success message instead of a page redirect, use fetch. The first version I wrote returned a 302, and fetch followed it cross-origin into a CORS error that looked like a server outage.
const res = await fetch("https://api.usesmartform.com/api/v1/f/YOUR_FORM_ID", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" }, // ← required
});
if (res.ok) {
const data = await res.json(); // includes next_url
status.textContent = "Thanks — we'll get back to you shortly.";
form.reset();
}
Always send Accept: application/json. You get JSON back (including next_url) instead of a redirect.
Gotcha #2: the honeypot needs three attributes
display:none alone isn't enough. I had style="display:none" and still saw bots post clean submissions, because they'd learned to skip hidden inputs. Adding tabindex="-1" (keeps it out of keyboard nav) and autocomplete="off" (keeps password managers out) closed the gap.
To actually test the filter I removed the honeypot for a day, watched bot traffic arrive, then put it back and watched those same submissions get dropped server-side — silently, with no email and no quota deduction.
Spam: layer it, don't pick one
| Method | Free | User friction | Catches |
|---|---|---|---|
| Honeypot field | Yes | None | The dumbest bots |
| Cloudflare Turnstile | Yes (≤20 widgets) | Almost none | Browser-level bots |
| reCAPTCHA v3 | Yes (≤1M/mo) | None | Script bots, but flags real users sometimes |
| ML filter (server-side) | Yes | None | Targeted / scripted spam |
What I ended up with: honeypot as the cheap first layer, a server-side classifier behind it, and Turnstile in front only on the demo-request form where a false positive costs us a lead.
When I'd use Astro Actions instead
If you already have output: 'server' and an adapter, use Astro Actions. If you're tying submissions to a logged-in user, or the form writes to your database, Actions is the right tool. I'm not against it — I just didn't want to add a server to a static site for one contact form.
It's also fine to mix. That site uses Actions for in-product feedback and an external endpoint for the marketing contact form. They're independent.
Verdict
For a static Astro site with one marketing form, the whole thing should be ~20 lines of HTML and zero infrastructure. That's what I shipped. The hosted-service part is my own tool, so treat that section as biased — but the underlying pattern (a plain POST to an endpoint) works with any provider, including one you run yourself.
I didn't want to write this as a sales pitch, but full disclosure: the external endpoint I used is my own product, SmartForm AI. The plain POST pattern works with any provider. If you want the full comparison matrix, I put the long-form version on my blog (linked in the comments).
Happy to answer questions in the comments.
Top comments (1)
For anyone who wants the full comparison table (and the 302 CORS gotcha in more detail), here's the long version on my blog: usesmartform.com/blog/astro-contac...