Astro form handling without serverless functions
How to receive form submissions in an Astro site without writing an API route, server endpoint, or serverless function.
Astro is islands-first by design - most pages don't run JavaScript on the client at all. A contact form is exactly the kind of feature that shouldn't drag in a server endpoint, a JavaScript framework, or a serverless function. This post covers four patterns, from "plain HTML form, zero JS" up to "client-side validation with progressive enhancement," and explains when each is the right choice.
Pattern 1: plain HTML form, zero JS
The simplest possible Astro contact form. The browser handles validation. The page submits via standard HTTP POST. No JavaScript ships to the client.
The pattern is the same form submission MDN documents in Sending form data - a standard HTML form post, no framework required.
---
// src/pages/contact.astro
---
<html lang="en">
<head>
<title>Contact us</title>
</head>
<body>
<form action="https://formspring.io/f/abc123" method="POST">
<label>
Name
<input name="name" required>
</label>
<label>
Email
<input type="email" name="email" required>
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<!-- honeypot -->
<div style="position:absolute;left:-9999px" aria-hidden="true">
<input type="text" name="website" tabindex="-1">
</div>
<button>Send</button>
</form>
</body>
</html>
That's the entire feature. Astro builds this to static HTML; no client:* directive, no JavaScript bundle, no Astro endpoint.
When the visitor submits, the browser POSTs to Formspring, Formspring stores the submission, and the visitor is redirected to your configured thank-you page (set in the Formspring dashboard). The whole flow happens server-to-server with zero client-side script.
Use when: contact form, simple submission, no fancy UX.
Pattern 2: AJAX submission with progressive enhancement
The form works without JavaScript (pattern 1), but if JavaScript is available, it submits via fetch and shows an inline thank-you message instead of a redirect.
---
// src/pages/contact.astro
---
<form id="contact-form" action="https://formspring.io/f/abc123" method="POST">
<input name="name" required>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<button>Send</button>
</form>
<div id="thanks" hidden>Thanks! We'll reply soon.</div>
<script>
const form = document.getElementById('contact-form');
const thanks = document.getElementById('thanks');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const res = await fetch(form.action, {
method: 'POST',
body: fd,
headers: { Accept: 'application/json' },
});
if (res.ok) {
form.hidden = true;
thanks.hidden = false;
}
});
</script>
Key detail: the <script> is a regular <script> tag, not a framework component. Astro ships it inline, no bundling overhead. If JavaScript fails, the form falls back to standard POST submission.
Use when: you want the polish of an inline thank-you state without the cost of a framework runtime.
Pattern 3: Astro endpoint as a passthrough
Sometimes you want to capture the submission server-side (to add auth headers, validate against your database, log to your stack) before forwarding to the form-host. Astro's API endpoints work for this.
// src/pages/api/contact.ts
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
const fd = await request.formData();
// Server-side validation, auth, logging here
const honeypot = fd.get('website');
if (honeypot) {
return new Response('OK', { status: 200 }); // silently drop
}
const upstream = await fetch('https://formspring.io/f/abc123', {
method: 'POST',
body: fd,
});
if (!upstream.ok) {
return new Response('Submission failed', { status: 502 });
}
return Response.redirect(new URL('/thanks', request.url), 303);
};
---
// src/pages/contact.astro
---
<form action="/api/contact" method="POST">
<input name="name" required>
<input type="email" name="email" required>
<textarea name="message" required></textarea>
<input type="text" name="website" style="display:none" tabindex="-1">
<button>Send</button>
</form>
This requires Astro running in SSR or hybrid mode (output: 'server' or output: 'hybrid' in astro.config.mjs).
Use when: you need server-side logic on submission. Most contact forms don't.
Pattern 4: client-side validation with React/Solid/Svelte island
If you want rich client-side validation (real-time error messages, conditional fields, multi-step flows), use a framework island. Astro lets you ship one component without forcing the whole app to be SPA.
---
// src/pages/contact.astro
import ContactForm from '../components/ContactForm.tsx';
---
<ContactForm client:load />
// src/components/ContactForm.tsx
import { useState } from 'react';
export default function ContactForm() {
const [sent, setSent] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
await fetch(e.currentTarget.action, { method: 'POST', body: fd });
setSent(true);
}
if (sent) return <p>Thanks! We'll reply soon.</p>;
return (
<form action="https://formspring.io/f/abc123" method="POST" onSubmit={onSubmit}>
<input name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<button>Send</button>
</form>
);
}
This ships React (or Solid, Svelte, Vue, Preact) only on the contact page, only for this one form. The rest of the site stays JavaScript-free.
Use when: complex forms - multi-step, conditional logic, file previews, real-time validation.
Common pitfalls
Forgetting enctype="multipart/form-data" for file uploads
Without it, files don't submit. Always set explicitly:
<form action="https://formspring.io/f/abc123" method="POST" enctype="multipart/form-data">
<input type="file" name="resume">
<button>Send</button>
</form>
CORS errors when using fetch
Configure your Formspring form's CORS origins to include your Astro site's URL. Default-deny means fetch from https://your-site.com to https://formspring.io will be blocked until you allowlist the origin. MDN's CORS reference covers the preflight rules and the headers the browser actually checks.
Hydration mismatch with client-side islands
If your island accesses window or browser-only APIs at first render, hydration breaks. Wrap browser-only code in useEffect (React) or onMount (Solid).
Form state lost on validation error
If you're using pattern 1 (no JS), and the form gets validation errors, the user loses everything they typed when the error page renders. Two fixes:
- Use HTML5
required/patternattributes for client-side validation (catches the most common errors). - Switch to pattern 2 (AJAX) so errors render inline without a page navigation.
What about Astro DB for storing submissions?
Astro DB (or any direct-to-database write from an endpoint) works, but you're back to running and maintaining infrastructure. The whole point of a hosted form backend is delegating that.
Use Astro DB if you have other server-side state (user accounts, sessions, queries that benefit from being co-located with content). For a pure contact form, a hosted backend is simpler.
Putting it all together
Most Astro contact forms should use pattern 1 or pattern 2. They're zero-JS or near-zero-JS, work without a server, work even if your build pipeline breaks, and require no maintenance. Pattern 3 makes sense only when you have a real server-side reason. Pattern 4 makes sense for complex, multi-step UX where the framework cost is justified.
Related from this desk
- JAMstack contact form: the complete 2026 guide - the framework-agnostic version of this post, with the decision tree for picking a backend.
- SvelteKit contact form without a server route - the same shape in SvelteKit, including the form-actions vs static-form trade-off.
- How to receive form submissions in Next.js without a backend - the App Router version.
- Static-site contact form checklist: shipping right in 2026 - the playbook covering CSP, honeypot, and the failure modes Astro inherits from static hosting.
- File uploads from HTML forms without S3 keys - for the multipart/form-data path mentioned above.
- Product side: form backend.
Formspring is host-agnostic - works equally well on Astro, Next.js, Hugo, Eleventy, plain HTML. Try the free plan - drop one URL into your <form action> and ship.
Written by Florian Wartner
Florian Wartner
Founder of Formspring and Pixel & Process. Senior full-stack engineer based in Lübeck, Germany. Building developer-first SaaS with EU data residency and honest pricing.
Elsewhere