Astro form handling without serverless functions
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.
---
// 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.
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.
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.
Florian Wartner
Founder of Formspring and Pixel & Process. Senior Laravel and Vue engineer based in Lübeck, Germany. Building developer-first SaaS with EU data residency and honest pricing.
Related posts
JAMstack contact form: the complete 2026 guide
Everything you need to ship a contact form on a JAMstack site without spinning up a backend - 5 approaches compared, with real code.
Static-site contact form: the 2026 playbook
The complete contact-form playbook for static sites in 2026 — from one-line HTML to multi-step JAMstack with signed webhooks, retention rules, and AI moderation.
File uploads from HTML forms without S3 keys
The four ways to handle file uploads from a static-site form. Tradeoffs, code, and why most teams pick option 4.