Astro Form Handling with StaticForm (Complete 2026 Guide)

Add a working form backend to your Astro site in 5 minutes. Step-by-step guide with HTML forms, React/Solid/Vue island components, spam protection, file uploads, and production patterns.

StaticForm Team

Astro Form Handling with StaticForm (Complete 2026 Guide)

Astro is brilliant for content sites. Fast builds, zero JavaScript by default, and that islands architecture is genuinely clever. But the moment you need a contact form, you hit a wall: Astro ships static HTML. There’s no server to receive form submissions, no built-in way to send notification emails, and no database to store anything.

We built StaticForm as an Astro form backend because we kept running into this exact problem. The form markup takes 5 minutes. The backend - receiving submissions, sending emails, filtering spam, storing data - takes a weekend. And then you spend another weekend three months later when bots discover your endpoint.

This guide covers everything: plain HTML forms, interactive React/Solid/Vue components using Astro islands, spam protection, file uploads, validation patterns, and production best practices. All with working code you can copy into your project today.

The Astro Form Problem

Astro generates static HTML at build time. That’s the whole point - it’s what makes Astro sites so fast. But static means there’s no server process running to handle a POST request when someone fills out your contact form.

You have a few options, and none of them are great:

Option 1: Astro SSR Mode

You can enable server-side rendering with an adapter (Node, Vercel, Netlify, Cloudflare). Now you have API endpoints. But you’ve also traded away one of Astro’s biggest strengths - static output. You need a runtime. You need hosting that supports it. And you still need to build all the form handling logic yourself: email transport, spam filtering, submission storage, error handling.

That’s a lot of moving parts for a contact form on a portfolio site.

Option 2: Serverless Functions

Write a Cloudflare Worker or Vercel Edge Function alongside your Astro site. Works fine, but now you’re maintaining two things instead of one. You still need an email provider (Postmark, Resend, SendGrid), spam protection, and somewhere to store submissions. Configuration overhead adds up fast.

Option 3: Form Backend Service

Point your HTML form at an external endpoint. The service handles receiving submissions, filtering spam, sending notifications, and storing everything. Your Astro site stays fully static. No adapter needed. No serverless functions. No email provider to configure.

This is what StaticForm does - and it’s what this guide is about.

The Spam Reality

Here’s something most form tutorials don’t mention: the moment your form goes live, bots will find it. We’re not talking about days or weeks. We track spam across our entire platform, and 84% of all submissions are spam. For every 10 real messages, roughly 8 are junk - SEO pitches, phishing attempts, and automated garbage.

If you’re building your own form handler, you’re building your own spam filter too. And the bots are getting smarter every month.

What Is StaticForm?

StaticForm is a form backend service. You create a form endpoint in our dashboard, point your HTML form’s action attribute at it, and we handle everything that happens after the user clicks “Submit”:

  • Submission storage - every submission is saved, searchable, and exportable
  • Email notifications - get an email for each real submission
  • Slack and Discord - pipe submissions into your team’s channels
  • Custom webhooks - POST submission data to any URL
  • Spam protection - multi-layer filtering that catches 84% of spam automatically
  • Execution logs - see exactly what happened with each notification (success, failure, retry)
  • EU data storage - submissions are stored in Europe for GDPR compliance

The important bit: spam doesn’t count against your submission quota. On most competitors, you pay for every submission including spam. If 84% of your traffic is bots, that’s 84% of your bill going to waste. On StaticForm, you only pay for legitimate submissions.

Plans start at €4/month for 500 submissions. Every form gets 10 free credits to test with - no credit card required.

Setting Up: Create Your Form Endpoint

Before writing any code, you need a StaticForm endpoint:

  1. Sign up at app.staticform.app
  2. Click “Create Form” and give it a name (e.g., “Astro Contact Form”)
  3. Copy your form endpoint URL:
https://api.staticform.app/api/v1/forms/019be6a7-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  1. Configure notifications - email, Slack, Discord, webhooks (or all of them)

That’s it for the backend. Now let’s write some Astro code.

Basic HTML Form (Zero JavaScript)

The simplest approach. Pure HTML, no client-side JavaScript, works with Astro’s default static output mode. This is the right choice for most contact forms.

---
// src/pages/contact.astro
import Layout from '../layouts/Layout.astro';
---

<Layout title="Contact">
  <main class="mx-auto max-w-xl py-12 px-4">
    <h1 class="text-3xl font-bold mb-8">Get in Touch</h1>

    <form
      action="https://api.staticform.app/api/v1/forms/YOUR_FORM_ID"
      method="POST"
      class="space-y-5"
    >
      <div>
        <label for="name" class="block text-sm font-medium text-gray-700">
          Name
        </label>
        <input
          type="text"
          id="name"
          name="name"
          required
          class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
        />
      </div>

      <div>
        <label for="email" class="block text-sm font-medium text-gray-700">
          Email
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
        />
      </div>

      <div>
        <label for="message" class="block text-sm font-medium text-gray-700">
          Message
        </label>
        <textarea
          id="message"
          name="message"
          rows="5"
          required
          class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
        ></textarea>
      </div>

      <!-- Honeypot field - invisible to real users, bots fill it in -->
      <div aria-hidden="true" style="position:absolute;left:-9999px">
        <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
      </div>

      <button
        type="submit"
        class="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white font-medium hover:bg-blue-700"
      >
        Send Message
      </button>
    </form>
  </main>
</Layout>

When submitted, StaticForm processes the data and redirects the user. Configure your success and error redirect URLs in the form settings in the dashboard.

The _gotcha field is a honeypot - it’s positioned off-screen so real users never see it, but automated bots fill it in. StaticForm uses this as one of several spam detection signals.

This works with zero configuration. No Astro adapter needed. No output: 'server'. Your site stays fully static.

React Component (Astro Islands)

For a better user experience - loading states, inline errors, success messages without a page redirect - you’ll want a client-side component. Astro’s islands architecture makes this easy: add client:load to hydrate a single interactive form while the rest of the page stays static HTML.

First, make sure you have the React integration installed:

npx astro add react

Then create the form component:

// src/components/ContactForm.tsx
import { useState, type FormEvent } from 'react';

const FORM_ENDPOINT = 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID';

type FormStatus = 'idle' | 'submitting' | 'success' | 'error';

export default function ContactForm() {
  const [status, setStatus] = useState<FormStatus>('idle');
  const [errorMessage, setErrorMessage] = useState('');

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus('submitting');
    setErrorMessage('');

    const formData = new FormData(e.currentTarget);

    try {
      const response = await fetch(FORM_ENDPOINT, {
        method: 'POST',
        body: formData,
      });

      if (!response.ok) {
        const problem = await response.json();
        throw new Error(problem.detail || 'Something went wrong');
      }

      setStatus('success');
    } catch (err) {
      setStatus('error');
      setErrorMessage(
        err instanceof Error ? err.message : 'Failed to send message'
      );
    }
  }

  if (status === 'success') {
    return (
      <div role="alert" className="rounded-lg bg-green-50 p-6 text-green-800">
        <h3 className="font-semibold text-lg">Message sent!</h3>
        <p className="mt-1">We'll get back to you as soon as we can.</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-5">
      <div>
        <label htmlFor="name" className="block text-sm font-medium text-gray-700">
          Name
        </label>
        <input
          type="text"
          id="name"
          name="name"
          required
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
                     focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">
          Email
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
                     focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
        />
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium text-gray-700">
          Message
        </label>
        <textarea
          id="message"
          name="message"
          rows={5}
          required
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
                     focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
        />
      </div>

      {/* Honeypot */}
      <div aria-hidden="true" className="absolute -left-[9999px]">
        <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
      </div>

      {status === 'error' && (
        <div role="alert" className="rounded bg-red-50 p-3 text-sm text-red-700">
          {errorMessage}
        </div>
      )}

      <button
        type="submit"
        disabled={status === 'submitting'}
        className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white font-medium
                   hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {status === 'submitting' ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  );
}

Now use it in an Astro page with client:load:

---
// src/pages/contact.astro
import Layout from '../layouts/Layout.astro';
import ContactForm from '../components/ContactForm';
---

<Layout title="Contact">
  <main class="mx-auto max-w-xl py-12 px-4">
    <h1 class="text-3xl font-bold mb-8">Get in Touch</h1>
    <ContactForm client:load />
  </main>
</Layout>

The client:load directive tells Astro to hydrate this component as soon as the page loads. The rest of the page - the layout, heading, navigation - stays as static HTML. Only the form gets JavaScript.

When to use client:load vs client:visible: Use client:load for forms above the fold (contact page hero). Use client:visible for forms further down the page (footer contact form) - the component won’t hydrate until the user scrolls to it, saving initial page load time.

SolidJS Component

If you prefer Solid over React (smaller bundle, fine-grained reactivity), the pattern is similar. Install the integration first:

npx astro add solid-js
// src/components/ContactForm.tsx (SolidJS)
import { createSignal, Show } from 'solid-js';

const FORM_ENDPOINT = 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID';

export default function ContactForm() {
  const [status, setStatus] = createSignal<'idle' | 'submitting' | 'success' | 'error'>('idle');
  const [errorMessage, setErrorMessage] = createSignal('');

  async function handleSubmit(e: SubmitEvent) {
    e.preventDefault();
    setStatus('submitting');
    setErrorMessage('');

    const formData = new FormData(e.currentTarget as HTMLFormElement);

    try {
      const response = await fetch(FORM_ENDPOINT, {
        method: 'POST',
        body: formData,
      });

      if (!response.ok) {
        const problem = await response.json();
        throw new Error(problem.detail || 'Something went wrong');
      }

      setStatus('success');
    } catch (err) {
      setStatus('error');
      setErrorMessage(
        err instanceof Error ? err.message : 'Failed to send message'
      );
    }
  }

  return (
    <Show
      when={status() !== 'success'}
      fallback={
        <div role="alert" class="rounded-lg bg-green-50 p-6 text-green-800">
          <h3 class="font-semibold text-lg">Message sent!</h3>
          <p class="mt-1">We'll get back to you as soon as we can.</p>
        </div>
      }
    >
      <form onSubmit={handleSubmit} class="space-y-5">
        <div>
          <label for="name" class="block text-sm font-medium text-gray-700">Name</label>
          <input
            type="text"
            id="name"
            name="name"
            required
            class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
          />
        </div>

        <div>
          <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
          <input
            type="email"
            id="email"
            name="email"
            required
            class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
          />
        </div>

        <div>
          <label for="message" class="block text-sm font-medium text-gray-700">Message</label>
          <textarea
            id="message"
            name="message"
            rows="5"
            required
            class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
          />
        </div>

        <div aria-hidden="true" style="position:absolute;left:-9999px">
          <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
        </div>

        <Show when={status() === 'error'}>
          <div role="alert" class="rounded bg-red-50 p-3 text-sm text-red-700">
            {errorMessage()}
          </div>
        </Show>

        <button
          type="submit"
          disabled={status() === 'submitting'}
          class="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white font-medium
                 hover:bg-blue-700 disabled:opacity-50"
        >
          {status() === 'submitting' ? 'Sending...' : 'Send Message'}
        </button>
      </form>
    </Show>
  );
}

Use it the same way in your Astro page:

<ContactForm client:load />

Solid components ship roughly 40% less JavaScript than their React equivalents, which matters on Astro sites where you’re optimizing for minimal client-side code.

Vue Component

For Vue users, same idea. Install the integration:

npx astro add vue
<!-- src/components/ContactForm.vue -->
<script setup lang="ts">
import { ref } from 'vue';

const FORM_ENDPOINT = 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID';

const status = ref<'idle' | 'submitting' | 'success' | 'error'>('idle');
const errorMessage = ref('');

async function handleSubmit(e: Event) {
  e.preventDefault();
  status.value = 'submitting';
  errorMessage.value = '';

  const formData = new FormData(e.target as HTMLFormElement);

  try {
    const response = await fetch(FORM_ENDPOINT, {
      method: 'POST',
      body: formData,
    });

    if (!response.ok) {
      const problem = await response.json();
      throw new Error(problem.detail || 'Something went wrong');
    }

    status.value = 'success';
  } catch (err) {
    status.value = 'error';
    errorMessage.value =
      err instanceof Error ? err.message : 'Failed to send message';
  }
}
</script>

<template>
  <div v-if="status === 'success'" role="alert" class="rounded-lg bg-green-50 p-6 text-green-800">
    <h3 class="font-semibold text-lg">Message sent!</h3>
    <p class="mt-1">We'll get back to you as soon as we can.</p>
  </div>

  <form v-else @submit="handleSubmit" class="space-y-5">
    <div>
      <label for="name" class="block text-sm font-medium text-gray-700">Name</label>
      <input
        type="text"
        id="name"
        name="name"
        required
        class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
      />
    </div>

    <div>
      <label for="email" class="block text-sm font-medium text-gray-700">Email</label>
      <input
        type="email"
        id="email"
        name="email"
        required
        class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
      />
    </div>

    <div>
      <label for="message" class="block text-sm font-medium text-gray-700">Message</label>
      <textarea
        id="message"
        name="message"
        rows="5"
        required
        class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
      />
    </div>

    <div aria-hidden="true" style="position: absolute; left: -9999px">
      <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
    </div>

    <div v-if="status === 'error'" role="alert" class="rounded bg-red-50 p-3 text-sm text-red-700">
      {{ errorMessage }}
    </div>

    <button
      type="submit"
      :disabled="status === 'submitting'"
      class="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white font-medium
             hover:bg-blue-700 disabled:opacity-50"
    >
      {{ status === 'submitting' ? 'Sending...' : 'Send Message' }}
    </button>
  </form>
</template>

All three framework components follow the same pattern: FormData from the native form, fetch to StaticForm, and state management for loading/success/error. Pick whichever framework you’re already using in your Astro project.

How StaticForm Spam Protection Works

This is the part that actually matters for production. Every form tutorial shows you how to submit data. Very few explain how to not drown in spam.

The Problem

We track spam across every form on our platform. The number is consistent: 84% of all submissions are spam. That means for every 10 messages your contact form receives, roughly 8 are garbage - SEO service pitches, phishing attempts, crypto scams, and automated link stuffing.

If you’re rolling your own form handler, you’re dealing with this yourself. A basic honeypot catches the dumbest bots. reCAPTCHA annoys your real users. And sophisticated bots bypass both.

Our Multi-Layer Approach

StaticForm doesn’t rely on a single technique. We stack multiple detection layers, each catching a different type of spam:

  1. Honeypot fields - the _gotcha hidden field catches simple bots that fill every input. Surprisingly effective against low-effort automation, and completely invisible to real users.

  2. Behavioral analysis - how quickly was the form filled out? Did the submission come from a headless browser? Was there any mouse movement or keyboard interaction? Bots behave differently from humans, and these signals are hard to fake.

  3. Content analysis - we scan submission content for known spam patterns. Link-heavy messages, common spam phrases, suspicious formatting, keyword stuffing. Each signal contributes to an overall spam score.

  4. Rate limiting - the same IP sending 50 submissions in 10 minutes? That’s not a human. We rate-limit by IP and by form to prevent brute-force spam floods.

  5. IP reputation - we check submitter IPs against known spam sources and proxy lists. Repeat offenders get blocked before their submission is even processed.

Each submission gets a spam probability score. High-confidence spam is filtered automatically and won’t count against your quota. Borderline cases are flagged for manual review in your dashboard.

Why This Matters for Billing

On most form backend services, every submission counts against your monthly quota - spam included. If you’re on a plan with 1,000 submissions and 84% are spam, you’re paying for 840 fake submissions. That’s most of your bill going to waste.

On StaticForm, spam submissions are filtered and don’t count. You only pay for real messages from real people. That’s not a marketing gimmick - it fundamentally changes the economics of running forms on the internet.

File Uploads

Need to accept file attachments - resumes, project briefs, screenshots? StaticForm handles file uploads through standard multipart/form-data. No special configuration needed.

Here’s a React component with file upload support:

// src/components/ApplicationForm.tsx
import { useState, type FormEvent } from 'react';

const FORM_ENDPOINT = 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID';

export default function ApplicationForm() {
  const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
  const [errorMessage, setErrorMessage] = useState('');

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus('submitting');

    const formData = new FormData(e.currentTarget);

    try {
      const response = await fetch(FORM_ENDPOINT, {
        method: 'POST',
        body: formData,
        // Do NOT set Content-Type header - the browser sets it
        // automatically with the correct multipart boundary
      });

      if (!response.ok) {
        const problem = await response.json();
        throw new Error(problem.detail || 'Upload failed');
      }

      setStatus('success');
    } catch (err) {
      setStatus('error');
      setErrorMessage(
        err instanceof Error ? err.message : 'Something went wrong'
      );
    }
  }

  if (status === 'success') {
    return (
      <div role="alert" className="rounded-lg bg-green-50 p-6 text-green-800">
        <h3 className="font-semibold">Application received!</h3>
        <p className="mt-1">We'll review your submission and get back to you.</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-5">
      <div>
        <label htmlFor="name" className="block text-sm font-medium text-gray-700">
          Full Name
        </label>
        <input
          type="text"
          id="name"
          name="name"
          required
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">
          Email
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="portfolio" className="block text-sm font-medium text-gray-700">
          Portfolio URL
        </label>
        <input
          type="url"
          id="portfolio"
          name="portfolio"
          placeholder="https://yoursite.com"
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
        />
      </div>

      <div>
        <label htmlFor="resume" className="block text-sm font-medium text-gray-700">
          Resume (PDF, max 10MB)
        </label>
        <input
          type="file"
          id="resume"
          name="resume"
          accept=".pdf,.doc,.docx"
          required
          className="mt-1 block w-full text-sm text-gray-500
                     file:mr-4 file:rounded file:border-0 file:bg-blue-50
                     file:px-4 file:py-2 file:text-sm file:font-medium
                     file:text-blue-700 hover:file:bg-blue-100"
        />
      </div>

      <div>
        <label htmlFor="cover" className="block text-sm font-medium text-gray-700">
          Why are you interested?
        </label>
        <textarea
          id="cover"
          name="cover"
          rows={4}
          required
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
        />
      </div>

      <div aria-hidden="true" className="absolute -left-[9999px]">
        <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
      </div>

      {status === 'error' && (
        <div role="alert" className="rounded bg-red-50 p-3 text-sm text-red-700">
          {errorMessage}
        </div>
      )}

      <button
        type="submit"
        disabled={status === 'submitting'}
        className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white font-medium
                   hover:bg-blue-700 disabled:opacity-50"
      >
        {status === 'submitting' ? 'Uploading...' : 'Submit Application'}
      </button>
    </form>
  );
}

Important: Don’t manually set the Content-Type header when using FormData with file uploads. The browser automatically sets it to multipart/form-data with the correct boundary string. Setting it yourself will break the upload.

File uploads are included in the submission data in your StaticForm dashboard and can be downloaded from there.

Validation Patterns

Client-side validation gives users instant feedback before hitting the network. Here’s a pattern that combines client-side checks with graceful handling of server-side errors:

// src/components/ValidatedContactForm.tsx
import { useState, type FormEvent } from 'react';

const FORM_ENDPOINT = 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID';

interface FieldErrors {
  name?: string;
  email?: string;
  message?: string;
}

export default function ValidatedContactForm() {
  const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
  const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
  const [serverError, setServerError] = useState('');

  function validate(data: FormData): FieldErrors {
    const errors: FieldErrors = {};

    const name = (data.get('name') as string || '').trim();
    const email = (data.get('email') as string || '').trim();
    const message = (data.get('message') as string || '').trim();

    if (name.length < 2) {
      errors.name = 'Name must be at least 2 characters';
    }

    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      errors.email = 'Please enter a valid email address';
    }

    if (message.length < 10) {
      errors.message = 'Message must be at least 10 characters';
    }

    return errors;
  }

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    // Client-side validation
    const errors = validate(formData);
    setFieldErrors(errors);

    if (Object.keys(errors).length > 0) {
      return; // Don't submit if there are validation errors
    }

    setStatus('submitting');
    setServerError('');

    try {
      const response = await fetch(FORM_ENDPOINT, {
        method: 'POST',
        body: formData,
      });

      if (!response.ok) {
        const problem = await response.json();
        throw new Error(problem.detail || 'Submission failed');
      }

      setStatus('success');
    } catch (err) {
      setStatus('error');
      setServerError(
        err instanceof Error ? err.message : 'Something went wrong. Please try again.'
      );
    }
  }

  if (status === 'success') {
    return (
      <div role="alert" className="rounded-lg bg-green-50 p-6 text-green-800">
        <h3 className="text-lg font-semibold">Thanks for reaching out!</h3>
        <p className="mt-1">We'll get back to you within 24 hours.</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} noValidate className="space-y-5">
      <div>
        <label htmlFor="name" className="block text-sm font-medium text-gray-700">
          Name *
        </label>
        <input
          type="text"
          id="name"
          name="name"
          required
          aria-invalid={!!fieldErrors.name}
          aria-describedby={fieldErrors.name ? 'name-error' : undefined}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
                     focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
        />
        {fieldErrors.name && (
          <p id="name-error" role="alert" className="mt-1 text-sm text-red-600">
            {fieldErrors.name}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">
          Email *
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          aria-invalid={!!fieldErrors.email}
          aria-describedby={fieldErrors.email ? 'email-error' : undefined}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
                     focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
        />
        {fieldErrors.email && (
          <p id="email-error" role="alert" className="mt-1 text-sm text-red-600">
            {fieldErrors.email}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium text-gray-700">
          Message *
        </label>
        <textarea
          id="message"
          name="message"
          rows={5}
          required
          aria-invalid={!!fieldErrors.message}
          aria-describedby={fieldErrors.message ? 'message-error' : undefined}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2
                     focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
        />
        {fieldErrors.message && (
          <p id="message-error" role="alert" className="mt-1 text-sm text-red-600">
            {fieldErrors.message}
          </p>
        )}
      </div>

      <div aria-hidden="true" className="absolute -left-[9999px]">
        <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
      </div>

      {serverError && (
        <div role="alert" className="rounded bg-red-50 p-3 text-sm text-red-700">
          {serverError}
        </div>
      )}

      <button
        type="submit"
        disabled={status === 'submitting'}
        className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white font-medium
                   hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {status === 'submitting' ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  );
}

Key details in this pattern:

  • noValidate on the form element disables the browser’s built-in validation popups so we control the error UX entirely
  • aria-invalid and aria-describedby connect error messages to their fields for screen reader users
  • Client-side validation runs synchronously before the fetch, giving instant feedback
  • Server-side errors are handled separately - validation catches formatting issues, but the server might reject submissions for other reasons (rate limiting, server errors)
  • role="alert" on error messages ensures screen readers announce them immediately

Production Best Practices

A few things we’ve learned from running forms across thousands of sites:

1. Always Include the Honeypot

The _gotcha field costs nothing to add and blocks a meaningful chunk of low-effort bots. Always include it. Position it off-screen with aria-hidden="true" so screen readers skip it too.

2. Use client:visible for Below-the-Fold Forms

If your form is in the footer or below a long content section, use client:visible instead of client:load:

<ContactForm client:visible />

This defers hydration until the user scrolls to the form, keeping your initial page load faster.

3. Handle Network Errors Gracefully

Not every failed submission is a server error. The user might be offline, on a flaky connection, or behind a corporate proxy that blocks external POST requests. Show a helpful message:

catch (err) {
  if (err instanceof TypeError && err.message === 'Failed to fetch') {
    setErrorMessage('Network error - check your internet connection and try again.');
  } else if (err instanceof Error) {
    setErrorMessage(err.message);
  } else {
    setErrorMessage('Something went wrong. Please try again.');
  }
}

4. Don’t Expose Your Form ID in Source Control

Your form endpoint URL contains your form ID. While the ID alone can’t do much damage (submissions still go through spam filtering), it’s good practice to use environment variables:

# .env
PUBLIC_STATICFORM_ENDPOINT=https://api.staticform.app/api/v1/forms/YOUR_FORM_ID
const FORM_ENDPOINT = import.meta.env.PUBLIC_STATICFORM_ENDPOINT;

In Astro, environment variables prefixed with PUBLIC_ are available in client-side code. Variables without the prefix are only available at build time or in server-side code.

5. Set Up Multiple Notification Channels

Don’t rely on a single notification method. Email can go to spam. Slack tokens expire. Set up at least two channels (e.g., email + Slack, or email + webhook) so you never miss a real submission.

StaticForm fires all configured notifications simultaneously and logs the result of each one independently. If email fails but the webhook succeeds, you’ll see both in the execution log.

6. Test with the Free Credits

Every form on StaticForm gets 10 free test credits. Use them. Submit test entries from your staging environment. Verify that notifications arrive where you expect them. Check the execution logs. Make sure everything works before pointing real traffic at it.

7. Consider GDPR If You Have EU Users

If your Astro site serves users in the EU, form submissions are personal data under GDPR. You need a legal basis for processing it (typically legitimate interest for contact forms), and you need to store it safely.

StaticForm stores all data in Europe by default. This doesn’t make you automatically GDPR-compliant - you still need a privacy policy and appropriate consent mechanisms - but it handles the data storage requirement without you needing to configure anything.

Wrapping Up

Astro gives you fast, static sites with zero JavaScript by default. StaticForm gives you a form backend that matches that philosophy - no server to maintain, no adapters to configure, no SMTP credentials to manage.

The shortest path to a working form on your Astro site:

  1. Create a free account at app.staticform.app
  2. Create a form and copy the endpoint URL
  3. Drop in the HTML form from the basic example above (or use a React/Solid/Vue component for better UX)
  4. Replace YOUR_FORM_ID with your actual endpoint
  5. Submit a test - 10 free credits per form, no credit card needed
  6. Set up notifications - email, Slack, Discord, webhooks

Five minutes from zero to a production form with multi-layer spam protection, EU data storage, and notification delivery to wherever your team works.

If your project grows past the free credits, plans start at €4/month for 500 submissions. Spam doesn’t count against your quota, so you’re paying for real messages only.

Try StaticForm free - 10 free credits per form, no credit card required.