How to build a contact form in Astro

You have an Astro site. It's fast, mostly static, and does exactly what you need. Then someone asks: "Does the site have a contact form?"
The static-first nature of Astro makes this a real question. You can't drop a server action in and call it done the way you might in Next.js. Astro's default output is HTML. There's no built-in route handler sitting behind every page. So what do you actually do?
Three paths. One that's almost always right for static sites. Here's all of them.
The options
1. An SSR API route
Astro supports API routes (.ts files in src/pages/api/) when you set output: 'server' or output: 'hybrid' in your config and add a hosting adapter. You write the POST handler, receive the form data, and then still need to wire up email sending (Resend, SendGrid, Nodemailer) and spam protection yourself. It works, but it's significant infrastructure for a contact form.
2. Astro Actions
Introduced in Astro 5, Actions give you typed server functions callable from the client. They're elegant. They also require an SSR adapter and a server runtime. And after you've set all that up, you still need to handle email delivery yourself.
3. A form backend service
You point your form at a hosted endpoint. The service stores submissions, filters spam, and forwards them to your email. No backend to write or maintain. For static Astro sites, this is almost always the right call.
This guide covers option 3, using Formtorch as the endpoint. The HTML and fetch patterns work with any form backend service.
The simplest version: plain HTML in an .astro file
Astro components are .astro files. They compile to static HTML. A plain <form> with an action attribute works with zero JavaScript:
---
// src/components/ContactForm.astro
---
<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
<label for="name">Name</label>
<input id="name" name="name" type="text" required />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send message</button>
</form>The browser handles the POST natively. Formtorch redirects to a default thank-you page. If you want to send the user to your own page after submission, add a hidden _redirect field:
<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
<input type="hidden" name="_redirect" value="https://yoursite.com/thank-you" />
<!-- your fields -->
</form>For many sites, this is enough. The contact form works, submissions arrive in your inbox, and you shipped no JavaScript. If you need loading states or want to keep the user on the page, read on.
Adding interactivity with a <script> tag
Astro has a first-class way to add client-side behavior without pulling in a framework: the <script> tag inside an .astro file. Unlike a raw <script> in plain HTML, Astro processes and bundles this. It gets deduped across component instances (safe to include in a reusable component), tree-shaken, and optimized with your build.
There's no "use client" directive here. That's a Next.js concept. In Astro, the <script> block is how you say "this code runs in the browser."
Here's a complete contact form with loading, success, and error states:
---
// src/components/ContactForm.astro
---
<form id="contact-form">
<div>
<label for="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<p id="form-error" style="display: none;">Something went wrong. Please try again.</p>
<button id="submit-btn" type="submit">Send message</button>
</form>
<div id="form-success" style="display: none;">
<h3>Message sent.</h3>
<p>Thanks for reaching out. I'll get back to you soon.</p>
</div>
<script>
const form = document.querySelector<HTMLFormElement>("#contact-form")!;
const submitBtn = document.querySelector<HTMLButtonElement>("#submit-btn")!;
const errorMsg = document.querySelector<HTMLParagraphElement>("#form-error")!;
const successDiv = document.querySelector<HTMLDivElement>("#form-success")!;
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorMsg.style.display = "none";
submitBtn.disabled = true;
submitBtn.textContent = "Sending\u2026";
try {
const data = new FormData(form);
const res = await fetch("https://formtorch.com/f/YOUR_FORM_ID", {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: data,
});
if (!res.ok) throw new Error("Submission failed");
form.style.display = "none";
successDiv.style.display = "block";
} catch {
errorMsg.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = "Send message";
}
});
</script>A few things worth noting:
X-Requested-With: XMLHttpRequest: this header tells Formtorch to respond with JSON instead of an HTML redirect. Without it, a 200 still works but you'd get HTML back, which isn't useful inside a fetch handler.
FormData: works with any input type, including file uploads, and you don't need to manually list every field name. Add a field later and the submission still works without touching the fetch code.
The script block is TypeScript-aware: Astro processes the script, so the <HTMLFormElement> generics work if your project has TypeScript configured. The non-null assertions (!) are safe here because these elements are always present in the same component.
Keeping the endpoint in an env variable
Hardcoding the Formtorch URL is fine for a quick prototype. If you're committing this to git, you'll want the form ID in an environment variable instead.
Astro 6 ships astro:env, a type-safe environment variable system introduced as stable in Astro 5. You define your schema in astro.config.mjs, and Astro validates variables at build time and provides typed imports.
Define the schema in your Astro config:
// astro.config.mjs
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
schema: {
FORM_ENDPOINT: envField.string({
context: "client",
access: "public",
}),
},
},
});Create a .env file at your project root:
FORM_ENDPOINT=https://formtorch.com/f/abc123Then import it in your script block:
<script>
import { FORM_ENDPOINT } from "astro:env/client";
// ...use FORM_ENDPOINT in your fetch call
const res = await fetch(FORM_ENDPOINT, {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: data,
});
</script>The context: 'client' and access: 'public' settings tell Astro that this variable is safe to bundle into client JavaScript. If you're on an older Astro setup without astro:env, use import.meta.env.PUBLIC_FORM_ENDPOINT instead. The PUBLIC_ prefix is what tells Astro to expose the variable to the browser; variables without it are stripped out of the client bundle entirely.
Add .env to your .gitignore and commit a .env.example with the variable
name but not the value, so collaborators know what to configure.
Using a React island
If your project already has @astrojs/react installed, you can drop in the same ContactForm.tsx component from the React contact form guide with one addition: the client:load directive.
---
// src/pages/contact.astro
import { ContactForm } from "../components/ContactForm";
---
<html>
<body>
<h1>Contact</h1>
<ContactForm client:load />
</body>
</html>The client:load directive tells Astro to hydrate this component in the browser immediately on page load. Without it, the React component renders to static HTML at build time and stays inert. Other options: client:idle (hydrates when the browser is idle) and client:visible (hydrates when the component scrolls into view, which is useful for forms below the fold).
The React component itself is unchanged from the standalone React version. It uses useState, fetch, and FormData. Astro handles bundling React only for the components that use it.
Do not add "use client" to React components used in Astro. That directive is
specific to Next.js and has no effect here. The client:load directive on the
component usage is how Astro controls hydration.
Where to get your form ID
You'll need a Formtorch account and a form created in the dashboard. The endpoint appears immediately when you create the form.
Create a project
Sign in at formtorch.com and click New Project. Projects are containers for your forms.
Create a form
Inside your project, click New Form. Give it a name. The endpoint URL appears immediately.
Copy the endpoint
Your endpoint looks like https://formtorch.com/f/abc123. Set this as
FORM_ENDPOINT in your .env file.
Set up notification emails
Go to Form Settings โ Notifications and add your email. Confirm it, and you'll get an email for each new submission.
What about spam?
Formtorch runs TorchWarden on every submission. It checks for common spam signals (bots filling hidden fields, rapid-fire identical submissions, keyword patterns) without adding friction for real users. Nothing to configure; it's on by default.
If you're building a custom backend, a honeypot field is the most practical starting point: a hidden input that real users never fill, but bots usually do.
<!-- Add inside your form. Hide with CSS, not type="hidden" -->
<input
name="_honeypot"
tabindex="-1"
autocomplete="off"
style="display: none;"
/>For a full breakdown of what makes forms attractive to bots and which defenses hold up, see How to prevent spam in contact forms.
Wrapping up
The three paths covered:
- Plain HTML form: zero JavaScript, browser handles the POST, Formtorch redirects to a thank-you page. Works on any static Astro site.
- Astro
<script>block: client-side interactivity without a framework, processed and bundled by Astro, no"use client"needed. - React island: drop in your existing
ContactForm.tsxwithclient:load, Astro bundles React only for the components that need it.
Env variables go in astro:env (Astro 5+) with context: 'client', access: 'public', or in import.meta.env.PUBLIC_* for older setups. Either way, the PUBLIC_ part is what exposes the variable to the browser.
If you're curious about what a form backend handles under the hood, The hidden complexity of form handling walks through everything that happens between a user clicking submit and an email landing in your inbox.
Need a form endpoint for your Astro site?
Create a free Formtorch account and get your endpoint URL in under two minutes.

