How to create a contact form in Astro
Astro is one of the best frameworks for building fast, content-focused websites. Since Astro generates static HTML by default, adding a contact form requires pointing the form at an external backend service rather than processing submissions server-side.
This guide walks through building a production-ready contact form in Astro, from basic markup to AJAX submission with spam protection.
Basic Astro contact form
Create a new page at src/pages/contact.astro:
------
<html lang="en"> <head> <title>Contact Us</title> </head> <body> <form action="https://api.formsfort.com/submit" method="POST"> <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<label for="name">Name</label> <input type="text" id="name" name="name" autocomplete="name" required />
<label for="email">Email</label> <input type="email" id="email" name="email" autocomplete="email" required />
<label for="message">Message</label> <textarea id="message" name="message" rows="5" required></textarea>
<button type="submit">Send message</button> </form> </body></html>This works immediately. Astro outputs the HTML as-is, and the browser handles the form submission to the external endpoint.
Adding spam protection
Honeypot field
Add a hidden field inside the form to catch basic bots:
<input type="text" name="botcheck" style="position: absolute; left: -9999px; opacity: 0;" tabindex="-1" autocomplete="off"/>Cloudflare Turnstile
Add Turnstile for stronger bot protection:
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITE_KEY"></div><script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>Place the Turnstile widget before the submit button. The response token is automatically included in the form submission.
Adding validation
Use HTML5 validation attributes for client-side validation:
<label for="name">Name</label><input type="text" id="name" name="name" autocomplete="name" minlength="2" maxlength="100" required/>
<label for="email">Email</label><input type="email" id="email" name="email" autocomplete="email" required />
<label for="message">Message</label><textarea id="message" name="message" rows="5" minlength="10" maxlength="5000" required></textarea>AJAX submission in Astro
For a smoother experience without page reloads, add a client-side script:
------
<form id="contactForm" action="https://api.formsfort.com/submit" method="POST"> <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" /> <input type="hidden" name="botcheck" style="position:absolute;left:-9999px;opacity:0" tabindex="-1" autocomplete="off" />
<label for="name">Name</label> <input type="text" id="name" name="name" required />
<label for="email">Email</label> <input type="email" id="email" name="email" required />
<label for="message">Message</label> <textarea id="message" name="message" rows="5" required></textarea>
<button type="submit" id="submitBtn">Send message</button> <p id="formStatus" aria-live="polite"></p></form>
<script> const form = document.getElementById("contactForm"); const status = document.getElementById("formStatus"); const btn = document.getElementById("submitBtn");
form.addEventListener("submit", async (e) => { e.preventDefault(); btn.disabled = true; btn.textContent = "Sending..."; status.textContent = "";
try { const response = await fetch(form.action, { method: "POST", body: new FormData(form), });
if (response.ok) { status.textContent = "Message sent successfully. We will be in touch."; form.reset(); } else { const data = await response.json().catch(() => null); status.textContent = data?.message ?? "Something went wrong. Please try again."; } } catch { status.textContent = "Network error. Please check your connection and try again."; } finally { btn.disabled = false; btn.textContent = "Send message"; } });</script>The script intercepts the form submission, sends it via fetch(), and updates the UI with the result. The aria-live="polite" attribute ensures screen readers announce status changes.
Using Astro environment variables for the access key
Store the access key in an environment variable instead of hardcoding it:
---const accessKey = import.meta.env.PUBLIC_FORMSFORT_ACCESS_KEY;---
<form action="https://api.formsfort.com/submit" method="POST"> <input type="hidden" name="access_key" value="{accessKey}" /> <!-- rest of form --></form>Create a .env file:
PUBLIC_FORMSFORT_ACCESS_KEY=ff_your_key_hereOnly variables prefixed with PUBLIC_ are exposed to the client bundle in Astro.
Contact form with Astro islands (React, Vue, Svelte)
If you prefer using a framework component for the form, create an island:
import { useState } from "react";
export default function ContactForm({ accessKey }: { accessKey: string }) { const [status, setStatus] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); setStatus("Sending...");
const response = await fetch("https://api.formsfort.com/submit", { method: "POST", body: new FormData(e.currentTarget), });
setStatus(response.ok ? "Message sent." : "Something went wrong."); }
return ( <form onSubmit={handleSubmit}> <input type="hidden" name="access_key" value={accessKey} /> <input type="email" name="email" required placeholder="Email" /> <textarea name="message" required placeholder="Message" /> <button type="submit">Send</button> <p>{status}</p> </form> );}Use it in your Astro page with a client directive:
---import ContactForm from "../components/ContactForm";const accessKey = import.meta.env.PUBLIC_FORMSFORT_ACCESS_KEY;---
<ContactForm client:load {accessKey} />Redirect after submission
For standard (non-AJAX) submissions, redirect to a thank-you page:
<input type="hidden" name="_redirect" value="https://yoursite.com/thanks" />Create the thank-you page at src/pages/thanks.astro.
Summary
Astro contact forms work by pointing standard HTML forms at a hosted form backend endpoint. No server adapters, API routes, or serverless functions are needed. Add spam protection with honeypot fields and CAPTCHA, validate with HTML5 attributes, and optionally handle submission via AJAX for a smoother user experience. The result is a fast, production-ready contact form that works with Astro’s static output.
Frequently asked questions
Does Astro support form submissions?
Astro generates static HTML by default, so forms need an external backend to receive submissions. Point the form action at a hosted form endpoint like FormsFort, and Astro handles everything else as standard HTML.
Do I need Astro server adapters for forms?
No. With a hosted form backend, you do not need @astrojs/node, @astrojs/vercel, or any server adapter. The form posts directly to the external endpoint from the browser.
Can I use Astro content collections with forms?
Content collections are for managing markdown and data files, not for processing form submissions. Use a form backend service for submission handling.
How do I add a contact form to an Astro island?
Use a framework component (React, Vue, Svelte) with client:load or client:visible directive. The component handles form submission via fetch() to the form backend endpoint.
What is the best spam protection for Astro forms?
Combine a honeypot field, domain restriction, and Cloudflare Turnstile CAPTCHA. All three work with standard HTML forms in Astro without any server-side code.
Get started free
Ready to add forms to your static site?
No backend required. Point your HTML form at FormsFort and start receiving submissions in minutes.