You build an Astro site. It's fast, it's static, it deploys anywhere. Then someone asks for a contact form and you remember that <form> needs somewhere to POST — and you don't have a server.
Here are the four options that actually exist, when each one is right, and the details nobody mentions until they bite you.
Option 1: mailto: — don't
<form action="mailto:you@example.com" method="POST">
It seems to work on your machine. In the wild it opens whatever the visitor's browser thinks is their mail client, which for most people is nothing at all. They see a broken page or a download prompt, and you never learn that they tried. It also publishes your address to every scraper on the internet.
Skip it.
Option 2: Astro SSR with an API route
Astro can run on a server. Add an adapter, switch the route to server-rendered, and you can handle the POST yourself:
// src/pages/api/contact.js
export const prerender = false;
export async function POST({ request }) {
const data = await request.formData();
// ...validate, then send mail with your provider of choice
await sendMail({
to: 'you@example.com',
subject: 'New contact form submission',
text: `${data.get('email')}\n\n${data.get('message')}`,
});
return new Response(null, { status: 303, headers: { Location: '/thanks' } });
}
When this is right: you already run the site with an adapter, and you want the submission to touch your own database or business logic.
What it costs: a host that runs a server, an email provider account and its API key, deliverability setup (SPF, DKIM, DMARC — get these wrong and your mail silently lands in spam), spam filtering, and somewhere to store submissions when the mail fails. That's a weekend, not an afternoon, and it's a weekend you spend again on the next site.
Option 3: A serverless function
Netlify Functions, Vercel Functions, Cloudflare Workers. Same code as above, no server to keep alive.
When this is right: you're already on that platform and comfortable there.
What it costs: everything from option 2 except the server, plus a tie to that platform. Move the site to a different host and the form stops working with it.
Option 4: A hosted form backend
Point the form's action at someone else's URL. They receive the POST, store it, email you, and give you an inbox. No server, no API key, no deliverability setup.
There are plenty: Formspree, Basin, Formcarry, Web3Forms, Netlify Forms if you're on Netlify. I build CatchForm, so that's what the code below uses — the shape is the same whichever you pick.
When this is right: the form is a contact form. If submissions just need to reach a human, running infrastructure for that is a hobby, not a requirement.
The rest of this post is option 4 done properly in Astro.
The component
Start with the version that works when JavaScript doesn't. Astro is good at this — no client-side JS at all:
---
// src/components/ContactForm.astro
const endpoint = `https://catchform.dev/f/${import.meta.env.PUBLIC_FORM_TOKEN}`;
---
<form action={endpoint} method="POST" class="contact">
<!-- Honeypot. Real people never fill a hidden field; bots fill everything.
Keep it empty and unlabelled, and keep it out of the tab order. -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" hidden />
<label for="email">Your email</label>
<input id="email" type="email" name="email" required autocomplete="email" />
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
<button type="submit">Send</button>
</form>
That is a working contact form. The browser posts it, the backend stores it, you get an email. It works with JS disabled, on a slow phone, in a text browser.
Three things worth noticing:
The token goes in the client. It has to — the browser is doing the POST. PUBLIC_ is Astro's marker for exactly this. The token identifies which form the submission belongs to; it isn't a secret and can't read anything.
Field names are yours. There's no schema to configure. Whatever your HTML sends is what gets stored, so renaming message to enquiry needs no change anywhere else.
The honeypot is a real input, not a comment. hidden keeps it off the screen, tabindex="-1" keeps keyboard users out of it, autocomplete="off" keeps the browser from helpfully filling it in — that last one matters, because a password manager that fills every field will get your visitor's message rejected as spam.
Upgrading to fetch()
Full page navigation is fine, but you probably want to keep the visitor on the page. Add JS as an enhancement, so the form still works without it:
<script>
const form = document.querySelector<HTMLFormElement>('.contact');
const status = document.querySelector<HTMLParagraphElement>('#form-status');
form?.addEventListener('submit', async (event) => {
event.preventDefault();
const button = form.querySelector('button');
button.disabled = true;
status.textContent = 'Sending…';
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
// 422 means the honeypot caught it. Show the same thank-you: telling a
// bot it was rejected only teaches it what to change. If real people are
// hitting this, your honeypot is being autofilled — see autocomplete="off".
if (response.ok || response.status === 422) {
form.reset();
status.textContent = 'Thanks — I will get back to you.';
} else {
const body = await response.json().catch(() => ({}));
status.textContent = body.error ?? 'Something went wrong. Please try again.';
}
} catch {
status.textContent = 'Could not reach the server. Please try again.';
} finally {
button.disabled = false;
}
});
</script>
<p id="form-status" role="status" aria-live="polite"></p>
role="status" with aria-live="polite" matters more than it looks: without it a screen reader user gets no feedback at all, because visually the only thing that changed is a paragraph they never focus.
The gotcha that will cost you an hour
If your form has a redirect URL configured, fetch() won't see JSON. The backend answers a successful submission with a 302 to that URL, fetch follows it, and response.ok is true for a page you never wanted. You get JSON only when no redirect is configured.
So pick one, deliberately: a redirect URL for the no-JavaScript flow, or an empty redirect and JSON for the fetch flow. If you want both — the plain form as a fallback and JS on top — leave the redirect empty and handle the success state yourself in both paths, with the <noscript> case landing on the JSON response.
The other one
Check that your backend actually sends CORS headers. A cross-origin fetch needs Access-Control-Allow-Origin on the response, and a simple form POST doesn't. If they're missing, the failure is nasty: the request goes through and your submission is stored, but the browser refuses to let JS read the response, so your visitor sees an error and sends it again. You get duplicates and nobody understands why.
Test it before you ship:
curl -i -X OPTIONS https://your-backend.example/your-endpoint \
-H "Origin: https://yoursite.example" \
-H "Access-Control-Request-Method: POST"
You want access-control-allow-origin in the output. (I found this missing in my own service while writing this post. It's fixed now — but check yours.)
Handling the response
Whatever backend you use, decide what happens on each outcome before your visitors find out for you:
| What happened | Typical response | What the visitor should see |
|---|---|---|
| Accepted |
200 with a JSON body |
a thank-you, and a cleared form |
| Caught as spam | 422 |
the same thank-you — never tell a bot it was caught |
| Over the monthly quota | 429 |
"we could not accept this right now", and an email to you |
| Bad or inactive token | 404 |
a generic failure, plus an alert to yourself |
That third row is the one people skip. On a free plan the quota is not theoretical, and a form that silently stops accepting messages is worse than no form.
Where to put the token
# .env
PUBLIC_FORM_TOKEN=your-token
// src/env.d.ts — so a typo becomes a build error, not a broken form
interface ImportMetaEnv {
readonly PUBLIC_FORM_TOKEN: string;
}
And add the variable to your host's build settings. Forgetting that is the single most common way this breaks in production: import.meta.env.PUBLIC_FORM_TOKEN becomes undefined, the action becomes .../f/undefined, and every submission 404s. Guard it if you like:
---
const token = import.meta.env.PUBLIC_FORM_TOKEN;
if (!token) throw new Error('PUBLIC_FORM_TOKEN is not set');
---
An Astro build that fails loudly beats a deployed form that fails quietly.
Which option should you pick
If the form's job is to get a message to a person: option 4, and spend the afternoon on something else.
If the submission has to touch your own data — create a record, check stock, trigger a workflow — write the endpoint yourself. Options 2 and 3 exist for that, and a form backend would only be in the way.
If you're already on Netlify and never plan to leave: Netlify Forms is right there and costs you nothing extra to try.
I build CatchForm, the form backend used in the examples — free plan is 1 form and 100 submissions a month, no card. The Astro-specific version of this guide lives here. Happy to answer questions about any of the four options in the comments, including the ones that don't involve me.
Top comments (0)