Astro Guide

Astro Form Guide

Build a copy-pasteable Astro form that posts straight to Formigo - no server, no plugins, no fuss. Wire up an Astro contact form with client-side fetch, spam protection, and success/error handling.

Why Astro + Formigo

Static-first, zero backend

Ship a fast Astro contact form with no server to maintain

Astro ships static HTML by default - which is great for speed, but means there's no server waiting to catch your form submissions. That's exactly where Formigo fits: your Astro form posts directly to a Formigo endpoint (formigo.io/f/your-form) and we handle delivery to email, an API, or wherever you've pointed it.

  • JSON or form-encoded - Formigo accepts both, and it's CORS-enabled, so a browser fetch just works.
  • Spam protection built in - add a honeypot (_formigo_hp) and a load timestamp (_formigo_t) and bots get filtered out quietly.
  • Friendly responses - any 2xx means success; a 429 returns { retry_after } so you can tell the user when to try again.

The recommended path is the client-side fetch below - it works on a fully static Astro site with no adapter. If you're running Astro in SSR mode and want to keep the endpoint off the client, jump to the Server Endpoint variant.

The .astro Form Component

Astro contact form (recommended)

Static markup + an inline client script doing the fetch

Drop this into src/components/ContactForm.astro and render it from any page (for example <ContactForm /> in src/pages/contact.astro). The markup is plain HTML; the <script> runs in the browser, sets the spam fields on load, and submits via fetch. The action and method attributes mean the form still submits natively if that script never loads.

Step 1: Create the component

The frontmatter (between the --- fences) runs at build time. We use it only to configure the endpoint, then reference it in the markup.

---
// src/components/ContactForm.astro
// Replace "your-form" with your real Formigo form slug.
const FORMIGO_ENDPOINT = "https://formigo.io/f/your-form";
---

<!-- action/method make this work even if the script below never loads -->
<form id="contact-form" class="contact-form" action={FORMIGO_ENDPOINT} method="POST">
  <div class="field">
    <label for="name">Name *</label>
    <input type="text" id="name" name="name" required />
  </div>

  <div class="field">
    <label for="email">Email *</label>
    <input type="email" id="email" name="email" required />
  </div>

  <div class="field">
    <label for="message">Message *</label>
    <textarea id="message" name="message" rows="5" required></textarea>
  </div>

  <!-- Spam protection: honeypot + load timestamp (set by the script below) -->
  <input
    type="text"
    name="_formigo_hp"
    value=""
    tabindex="-1"
    autocomplete="off"
    style="display:none"
    aria-hidden="true"
  />
  <input type="hidden" name="_formigo_t" value="" />

  <button type="submit">Send Message</button>
  <p id="status" role="status" aria-live="polite"></p>
</form>

<script define:vars={{ endpoint: FORMIGO_ENDPOINT }}>
  const form = document.getElementById("contact-form");
  const status = form.querySelector("#status");
  const submitBtn = form.querySelector("button[type=submit]");
  const timestamp = form.querySelector("input[name=_formigo_t]");

  // Stamp the form the moment the page loads. Instant submits look like bots.
  timestamp.value = Math.floor(Date.now() / 1000);

  form.addEventListener("submit", async (event) => {
    event.preventDefault();

    submitBtn.disabled = true;
    submitBtn.textContent = "Sending...";
    status.textContent = "";
    status.className = "";

    const data = Object.fromEntries(new FormData(form));

    try {
      const response = await fetch(endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });

      if (response.ok) {
        status.className = "success";
        status.textContent = "Thanks! Your message is on its way.";
        form.reset();
        timestamp.value = Math.floor(Date.now() / 1000);
      } else if (response.status === 429) {
        const { retry_after } = await response.json();
        status.className = "error";
        status.textContent = `Whoa there - try again in ${retry_after}s.`;
      } else {
        status.className = "error";
        status.textContent = "Something went wrong. Please try again.";
      }
    } catch (error) {
      status.className = "error";
      status.textContent = "Network error. Please try again.";
      console.error("Formigo submit failed:", error);
    } finally {
      submitBtn.disabled = false;
      submitBtn.textContent = "Send Message";
    }
  });
</script>

<style>
  .contact-form { max-width: 32rem; }
  .field { margin-bottom: 1rem; }
  label { display: block; margin-bottom: 0.5rem; font-weight: 600; }
  input,
  textarea {
    width: 100%;
    padding: 0.75rem;
    border: 2px solid #ddd;
    border-radius: 8px;
  }
  button {
    padding: 0.75rem 2rem;
    background: #FF6B9D;
    color: #fff;
    border: none;
    border-radius: 8px;
    font-weight: 600;
    cursor: pointer;
  }
  button:disabled { opacity: 0.5; cursor: not-allowed; }
  .success { color: #155724; }
  .error { color: #721c24; }
</style>

Step 2: Use it on a page

Import the component and render it wherever you want the form to appear.

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

<Layout title="Contact us">
  <main>
    <h1>Get in touch</h1>
    <ContactForm />
  </main>
</Layout>

Step 3: Swap in your form slug

Replace your-form in FORMIGO_ENDPOINT with the slug from your Formigo dashboard, deploy, and submit a test message. You're done - submissions land in your inbox (or wherever you configured delivery) without a single line of backend code.

Tip: prefer form-encoded over JSON? Send body: new URLSearchParams(new FormData(form)) and drop the Content-Type header - Formigo accepts both formats.

Without JavaScript

Native form POST

No fetch, no client script - the browser submits it

Astro's whole pitch is shipping HTML that works before any JavaScript runs, and a form can do the same. Point action at your Formigo endpoint, set method="POST", and the browser submits it natively - Formigo accepts form-encoded bodies just as happily as JSON.

---
// src/components/ContactFormNoJs.astro
const FORMIGO_ENDPOINT = "https://formigo.io/f/your-form";
---

<form action={FORMIGO_ENDPOINT} method="POST">
  <div class="field">
    <label for="name">Name *</label>
    <input type="text" id="name" name="name" required />
  </div>

  <div class="field">
    <label for="email">Email *</label>
    <input type="email" id="email" name="email" required />
  </div>

  <div class="field">
    <label for="message">Message *</label>
    <textarea id="message" name="message" rows="5" required></textarea>
  </div>

  <!-- Honeypot stays empty; bots fill it in -->
  <input
    type="text"
    name="_formigo_hp"
    value=""
    tabindex="-1"
    autocomplete="off"
    style="display:none"
    aria-hidden="true"
  />
  <input type="hidden" name="_formigo_t" value="" />

  <button type="submit">Send Message</button>
</form>

<!-- Optional: one line to stamp the load time, so instant submits look like bots.
     Without it the field is simply empty, which costs a few spam points. -->
<script is:inline>
  document.querySelector('input[name="_formigo_t"]').value =
    Math.floor(Date.now() / 1000);
</script>

What the visitor sees: a full page load. Formigo answers a native form POST with a redirect back to the page the form came from, so there's no success message unless you add one. If you want the visitor to stay put and read "Thanks!", use the fetch component above - it degrades to this exact behaviour when JavaScript fails to load.

SSR Server Endpoint (optional)

Proxy through an Astro endpoint

SSR only - requires an adapter (Node, Vercel, Netlify, etc.)

When to use this: only if you want to keep the Formigo endpoint off the client - for example to add server-side validation or hide a future API key. It needs an SSR adapter, plus either output: "server" or a per-route export const prerender = false on a static site. For a plain static site, the component above is all you need.

A server endpoint lives at src/pages/api/contact.ts and exports a POST handler. Your Astro form posts to /api/contact, and the endpoint forwards the data to formigo.io/f/your-form server-side.

// src/pages/api/contact.ts
// Requires an SSR adapter (Node, Vercel, Netlify, etc.).
// On a default (static) site, the `prerender = false` line below opts this
// route into on-demand rendering. With `output: "server"` it is optional.
import type { APIRoute } from "astro";

export const prerender = false;

const FORMIGO_ENDPOINT = "https://formigo.io/f/your-form";

export const POST: APIRoute = async ({ request }) => {
  // The client posts JSON, so read it as JSON (this includes the
  // _formigo_hp honeypot and _formigo_t timestamp fields).
  const payload = await request.json();

  const response = await fetch(FORMIGO_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });

  if (response.ok) {
    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }

  // Pass Formigo rate-limit info back to the browser.
  if (response.status === 429) {
    const { retry_after } = await response.json();
    return new Response(JSON.stringify({ ok: false, retry_after }), {
      status: 429,
      headers: { "Content-Type": "application/json" },
    });
  }

  return new Response(JSON.stringify({ ok: false }), { status: 502 });
};

On the client, point the same script at your endpoint instead of Formigo - just change one line:

// In ContactForm.astro, swap the endpoint:
const FORMIGO_ENDPOINT = "/api/contact";

// The fetch, honeypot, timestamp, and 429 handling stay exactly the same -
// your endpoint relays Formigo's response (including retry_after) straight through.

FAQ

Common questions about wiring an Astro form up to a form backend.

Do I need an SSR adapter to handle forms in Astro?

No. A form posts straight to your Formigo endpoint from the browser, so a fully static Astro build with no adapter and no output: server works. You only need an SSR adapter if you want to proxy submissions through your own /api route to keep the endpoint off the client or add server-side validation.

Does an Astro form work without JavaScript?

Yes. Give the form an action pointing at your Formigo endpoint and method="POST" and the browser submits it natively. Formigo accepts form-encoded bodies as well as JSON. The only thing you lose is the timestamp spam check, which needs one line of JavaScript to set, and the on-page success message: a native POST redirects the visitor back to the page they submitted from.

Why is my Astro form script not bundled?

Using define:vars on a script tag implies Astro's is:inline directive, so the script is written directly into the HTML instead of being bundled and processed. That is fine for a self-contained submit handler, but it means you cannot import modules inside it. If you need imports, drop define:vars and hardcode the endpoint or read it from a data attribute.

Can my Astro site post to Formigo from a different domain?

Yes. The submission endpoint is CORS-enabled, so a browser fetch from your own domain works without any proxy. By default a form accepts submissions from any origin; you can restrict it to specific domains in your form settings, and wildcards like *.example.com are supported.

How do I handle rate limiting in an Astro form?

Formigo allows 5 submissions per IP per form every 10 minutes and 20 submissions per IP across all forms per hour. Exceeding either returns HTTP 429 with a JSON body containing retry_after, the number of seconds until the limit resets. Check for response.status === 429 in your submit handler and show that number to the visitor.

Ready to wire up your Astro form?

Create a free Formigo form, grab your endpoint, paste the component above, and ship. No backend, no servers, no headaches.

Set up your first Astro contact form in minutes.