Astro form handling without serverless functions

Florian Wartner2026-05-07 7 min read

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/pattern attributes 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.

Ship your form in two minutes.

No credit card. 50 free submissions a month, every month.