Astro contact form with no client JavaScript
An Astro contact form does not need an island, an adapter, or a line of client JavaScript. A static build has no server, so the form posts somewhere else, and the browser already knows how to do that:
---
const endpoint = "https://api.formroute.dev/f/site_xyz";
---
<form action={endpoint} method="POST">
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button>Send</button>
</form>
That is the whole component. Drop it in src/components/ContactForm.astro, import it into any page, and the form works.
Why nothing is shipped to the browser here
Astro’s default is to send no JavaScript, and this form keeps that promise because it never needed any. The <form> element has serialised fields and sent a POST since long before frameworks existed. The browser reads action and method and makes the request itself.
This matters more on Astro than on most stacks. The reason you picked Astro is that pages arrive as HTML. A contact form that pulls in a client island, a form library and a validation package undoes that on the one page most likely to be someone’s first visit.
What Astro Actions need that a static site does not have
Astro has Actions, which let a form call a typed server function. They are a good fit for an app and a poor fit here, because they need a route rendered on demand: an adapter, a server runtime, and a host that runs it. Not a folder of files.
That is the trade, stated plainly:
| Approach | What it costs |
|---|---|
| External endpoint | Nothing. The site stays static and deploys anywhere |
| Astro Actions | An adapter, a server runtime, and a host that runs it |
| Your own API route | The same, plus you now own spam filtering and delivery |
If the form is the only dynamic thing on the site, adding a server runtime to support it is a large change bought for a small one.
Keeping the visitor on the page
A native POST navigates, and a plain success page is the most robust thing you can ship. If you want the visitor to stay, that is the one place a script earns its keep:
<form action={endpoint} method="POST" data-contact>
<!-- same fields -->
</form>
<script>
const form = document.querySelector("[data-contact]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch(form.action, {
method: "POST",
body: new FormData(form),
});
form.replaceWith(
Object.assign(document.createElement("p"), {
textContent: response.ok ? "Thanks, we got it." : "Something went wrong.",
}),
);
});
</script>
An inline <script> in an .astro file is bundled by Astro and scoped to that page, so you do not need a framework island for this. Reaching for one means shipping React to render a form the browser renders on its own. The vanilla JavaScript contact form covers the states and the failure path this version skips.
Validation has nowhere else to run
On most stacks, “validate on the server too” is advice. On a static Astro build it is the only option, because you have no server of your own. required and type="email" are a courtesy to the honest visitor and they are gone the moment a request arrives from something that is not your page.
So the rules have to hold where the POST lands. On FormRoute they are declared once per form and applied to every request:
email required|email
message required|min:10
That is also the only place to check formats the browser has never heard of, like a national ID number.
What a static host does with the response
One Astro-specific detail worth knowing before you ship. On a native POST the browser follows whatever the endpoint returns, and your static host is not involved: there is no route on your side to redirect to, so the confirmation page has to be a real page in your build, or the endpoint’s own. Build a /thanks page and point the endpoint at it, or use the script above and never leave.
The wider picture of what an endpoint does with a submission before it reaches you is in what a form backend is. FormRoute keeps the static half static: no adapter, no runtime, one endpoint. It is invite-only today: ask for one.