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 + a bundled 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 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";
---
<form id="contact-form" class="contact-form">
<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="position:absolute;left:-9999px"
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.
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.
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.