Why Astro contact forms are weird (and good)
Astro builds your site to static HTML by default. That's fantastic for performance, SEO and hosting cost — but it means the part of an Astro contact form most tutorials trip on is the same part that trips Hugo, Eleventy and plain HTML: the page itself has nowhere to send the submission. There's no Node process, no PHP runtime, no API route to handle the POST.
You have three real options. Option A: enable Astro's SSR with a Node, Vercel or Netlify adapter, write a server endpoint at src/pages/api/contact.ts, install Nodemailer, configure SMTP, deploy. Fifty lines of code, 30 minutes of work, ongoing SMTP password rotation. Option B: use Astro Actions (Astro 4.15+) — same server, just typed. Same SMTP problem. Option C: point the form's action attribute at a hosted form backend and let it handle email, spam filtering and storage. Three lines of HTML. Zero server code. The page stays fully static.
This tutorial walks through option C — the Astro contact form that ships in five minutes and never breaks because there's nothing to break. We'll use splitforms as the Astro form backend because the free tier covers 1,000 submissions per month, includes built-in spam filtering, and the integration is one HTML attribute.
The minimal working Astro contact form
Create src/components/ContactForm.astro. The Astro component is just an HTML fragment — no script tag at the top, no --- frontmatter --- with logic, just the markup:
---
// src/components/ContactForm.astro
const ACCESS_KEY = import.meta.env.PUBLIC_SPLITFORMS_KEY;
---
<form action="https://splitforms.com/api/submit" method="POST" class="contact">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<input type="hidden" name="redirect" value="https://yoursite.com/thanks" />
<label>
Your name
<input type="text" name="name" required autocomplete="name" />
</label>
<label>
Your email
<input type="email" name="email" required autocomplete="email" />
</label>
<label>
Your message
<textarea name="message" rows="5" required></textarea>
</label>
<button type="submit">Send message</button>
</form>
<style>
.contact { display: grid; gap: 14px; max-width: 32rem; }
.contact label { display: grid; gap: 6px; font-size: 14px; }
.contact input, .contact textarea {
padding: 10px 12px;
border: 1px solid #d4d4d8;
border-radius: 8px;
font: inherit;
}
.contact button {
padding: 12px 18px;
border: none;
border-radius: 8px;
background: #ff4f00;
color: #fff;
font-weight: 600;
cursor: pointer;
}
</style>Add the access key to your .env at the project root with the PUBLIC_ prefix so Astro exposes it to the client bundle:
# .env
PUBLIC_SPLITFORMS_KEY=sf_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxNow drop <ContactForm /> into any .astro page:
---
// src/pages/contact.astro
import Layout from '../layouts/Default.astro';
import ContactForm from '../components/ContactForm.astro';
---
<Layout title="Contact us">
<h1>Get in touch</h1>
<p>Drop us a line. We respond within one business day.</p>
<ContactForm />
</Layout>Run astro dev, open /contact, fill in the form and click submit. The browser POSTs to splitforms, the submission lands in your inbox within 30 seconds, and the visitor is redirected to /thanks. Total integration: one component, one env var, zero npm dependencies beyond what Astro ships with.
Adding a fetch-based success state (no full page reload)
The default form behavior reloads the page (or follows the redirect). That's fine for most contact forms but feels dated on a polished site. To swap the form for a thank-you message in place, add a tiny client-side handler. Astro lets you put a <script> tag at the bottom of the component — it gets bundled and shipped to the browser:
---
// src/components/ContactForm.astro
const ACCESS_KEY = import.meta.env.PUBLIC_SPLITFORMS_KEY;
---
<form id="contact" action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" rows="5" required></textarea></label>
<button type="submit">Send message</button>
<p id="status" role="status" aria-live="polite"></p>
</form>
<script>
const form = document.getElementById('contact') as HTMLFormElement;
const status = document.getElementById('status') as HTMLElement;
form.addEventListener('submit', async (e) => {
e.preventDefault();
status.textContent = 'Sending…';
const res = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' },
});
if (res.ok) {
form.reset();
status.textContent = 'Thanks — we received your message.';
} else {
status.textContent = 'Something went wrong. Please try again.';
}
});
</script>Sending the Accept: application/json header tells splitforms to return a JSON response instead of a redirect — perfect for client-side handling. The form still works without JavaScript because the action attribute is set; the script is progressive enhancement.
When to reach for Astro Actions instead
Astro Actions (added in Astro 4.15) give you typed server functions you can call from the client. They're a cleaner replacement for ad-hoc src/pages/api/ routes when you actually need server logic. For a contact form, they make sense in three specific situations:
- You need to write the submission to your own database, not just receive it.
- You need conditional logic (route to different teams based on the message body, run an LLM moderator).
- You're already running Astro in SSR mode for other reasons and want to keep the stack consistent.
Otherwise, the static action-attribute pattern wins on simplicity. Here's what an Action-based contact form looks like for comparison:
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
contact: defineAction({
accept: 'form',
input: z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(1),
}),
handler: async ({ name, email, message }) => {
// Forward to splitforms server-side so the access key never touches
// the client bundle. Useful if you want to keep the key private.
const body = new URLSearchParams({
access_key: import.meta.env.SPLITFORMS_KEY,
name, email, message,
});
const res = await fetch('https://splitforms.com/api/submit', {
method: 'POST',
body,
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error('Submission failed');
return { ok: true };
},
}),
};The trade-off: enable an SSR adapter (@astrojs/node, @astrojs/vercel, etc.), accept the runtime cost on every form post, and gain the ability to keep the access key server-side. Most marketing sites don't need this; the public access key is rate-limited and bound to the splitforms account, not a credential.
Spam protection on a static Astro form
A naked contact form gets 50–500 spam submissions per week within a month of going live. The cheap fix is a honeypot — a hidden field bots fill in but humans never see. splitforms drops any submission with a non-empty honeypot value automatically.
<form action="https://splitforms.com/api/submit" method="POST">
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<!-- Honeypot. Real users never fill this in. -->
<div aria-hidden="true" style="position:absolute;left:-9999px">
<label>Don't fill this if you're human:
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</label>
</div>
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>For sophisticated bots that skip honeypots, layer Cloudflare Turnstile on top. The Turnstile widget runs entirely on the client (so it works with a static Astro build) and splitforms verifies the resulting token server-side. Read honeypot vs reCAPTCHA for the benchmark numbers and the form spam protection guide for the layered approach.
Deploying and verifying the form works
The fastest path: astro build, deploy the dist/folder to Cloudflare Pages, Netlify, Vercel static, or any other host. The form has no runtime requirements — it's pure HTML pointed at an external API.
- Set the env var on the host. On Cloudflare Pages and Netlify the env var name is
PUBLIC_SPLITFORMS_KEY; on Vercel it's the same. Re-deploy once after setting it so the build picks up the value. - Submit a test form. Open the deployed contact page, fill in the fields with a real email you control, click submit. The submission appears in the splitforms dashboard immediately and arrives in your inbox within 30 seconds.
- Verify the redirect. If you set a
redirecthidden input, confirm the browser lands on the right thank-you URL. - Verify the reply-to header. Hit reply on the notification email — the To: should auto-populate with the visitor's submitted email address. That's splitforms parsing the field named
emailand using it as the reply-to.
If submissions don't arrive, the most common causes are: env var not set on the host (build still uses an undefined access key), the form action URL has a typo, or the email is going to spam (whitelist noreply@splitforms.com in your inbox). For a deeper checklist see contact form not working.
Where to go from here
The same one-attribute pattern works on every Astro page: a newsletter capture in the footer, a quote-request form on the pricing page, a feedback form on a docs page. Each form gets its own access key (or shares one — both work) and shows up in the dashboard tagged by URL.
If you outgrow the free tier, the splitforms Pro planat $5/mo covers 5,000 submissions and adds CC/BCC recipients. There's also a $59 4-year plan (15,000/mo for 48 months) for sites that already know they'll be running for years. The HTML in your form does not change when you upgrade — only the dashboard configuration does.
Related reading: contact forms on static sites, HTML form action complete guide, and best free form backends in 2026. For the dashboard-side configuration walkthrough, the splitforms docs cover webhooks, custom redirects, file uploads and CC routing.
Get a free access key
Sign up at /login (no credit card) or visit /free-contact-form to generate a key on the spot. Drop it into the Astro component above and your contact form is live.
FAQ
Does an Astro contact form need an API route or server adapter?
No. If you point the form's action attribute at a hosted form backend like splitforms, the static page handles the submission entirely in the browser — the POST goes directly to splitforms.com/api/submit. You can deploy to a static host (Netlify, Vercel static, Cloudflare Pages, GitHub Pages) without enabling SSR or installing a Node adapter. You only need an Astro API route if you want to proxy the submission server-side, which is rare and adds latency.
Can I use Astro Actions for the contact form instead?
Astro Actions (Astro 4.15+) let you call typed server functions from the client, which is great for app-like state, but for a one-off contact form they're overkill — you have to enable an SSR adapter, write the action, and either email the data yourself with Nodemailer (and configure SMTP credentials) or call a backend anyway. The static-action-attribute pattern with a hosted backend stays simpler and keeps the page deploy-able to any static host.
Where should I put the form component in an Astro project?
A pure-HTML form (no client-side JS) is just an .astro component in src/components/ — for example src/components/ContactForm.astro. Drop it into any page with `import ContactForm from '../components/ContactForm.astro'` and `<ContactForm />`. If you add fetch-based submission with success states, mark the form section as a client island (`<ContactForm client:load />`) or use a framework-specific island like a React component.
How do I prevent spam on an Astro contact form?
Add a honeypot field — a hidden input that bots fill but humans don't. splitforms drops any submission with a non-empty honeypot value automatically. For deeper coverage add Cloudflare Turnstile, which works on static Astro pages without a server because the Turnstile widget validates the token client-side and the splitforms backend re-verifies it. Read /blog/honeypot-vs-recaptcha for benchmark numbers.
Can I send Astro form submissions to Slack or Discord instead of email?
Yes. Configure a webhook destination in your splitforms dashboard pointing at a Slack incoming webhook or Discord webhook URL. Every submission then arrives in the channel as a formatted message — no extra Astro code required. See /blog/send-form-submissions-to-slack and /blog/send-form-submissions-to-discord.
Does the form work without JavaScript?
Yes. The minimal pattern in this tutorial is a plain HTML <form> tag inside an .astro file — it submits via the browser's native form behavior, with no JavaScript required. The browser POSTs to the action URL and follows the splitforms redirect to your thank-you page. JS-disabled visitors, search-engine crawlers, and screen readers all see a form that works.
How do I show a custom thank-you page after submission?
Add a hidden input named `redirect` with the URL you want visitors to land on after a successful submission: `<input type="hidden" name="redirect" value="https://yoursite.com/thanks">`. splitforms 302s the browser to that URL after the submission lands. Build the /thanks page as a normal Astro page with whatever layout matches your site.