Skip to content

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.

Updated 8 min read 1,442 words By Florian Wartner

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.

astro
---
// 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.

astro
---
// 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.

typescript
// 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);
};
astro
---
// 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.

astro
---
// src/pages/contact.astro
import ContactForm from '../components/ContactForm.tsx';
---
<ContactForm client:load />
tsx
// 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:

html
<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/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.

Related from this desk

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.

From the field

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.

Give your next important form a real home.

Start free with one form. Add ownership, private files, and clear history before responses pile up in inboxes.

·· no card · 50 submissions / mo · no countdown