我在 Astro 網站加入無需後端的聯絡表單
I Added a Contact Form to an Astro Site Without a Backend
開發者為 Astro 靜態網站加入聯絡表單,毋須在網站端部署後端。表單以純 HTML POST 至外部端點,無需 adapter 或伺服器;若用 fetch 顯示內嵌成功訊息,須設定 Accept: application/json。防垃圾表單的 honeypot 欄位則要加上 tabindex="-1" 和 autocomplete="off"。
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.
來源:DEV Community · dev.to