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.
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.
fetch just works._formigo_hp) and a load timestamp (_formigo_t) and bots get filtered out quietly.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.
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.
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>
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>
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.
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 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.
Common questions about wiring an Astro form up to a form backend.
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.
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.
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.
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.
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.
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.