Astro contact form
Astro renders your page to HTML at build time, and ezform takes the post from the browser, so nothing here needs an adapter, a server island or an API route. A static build and an SSR build set this up identically.
Create the form in the console first. It gives you a form ID for the URL and the name of a honeypot field for your markup. Neither is a secret, and both end up in the HTML you ship.
A reusable component
Take both values as props rather than reading them inside the component. A form ID is per form, so a site with a contact form and a careers form has two, and a component that reaches for one global is a component that can only be used once.
---
interface Props {
formId: string
/*
* Required, with no default. A honeypot whose name does not match the form
* record is not a weak honeypot, it is an absent one: nothing ever arrives
* under the name the gate checks. Without a default, the first page that
* forgets it fails the build instead of shipping a form with no spam gate.
*/
honeypotField: string
}
const { formId, honeypotField } = Astro.props
const action = `https://api.ezform.io/f/${formId}`
---
<form action={action} method="POST">
<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>
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label for={honeypotField}>Company website</label>
<input id={honeypotField} name={honeypotField} type="text" tabindex="-1" autocomplete="off" />
</div>
<button type="submit">Send</button>
</form>
---
import ContactForm from '../components/ContactForm.astro'
---
<ContactForm
formId={import.meta.env.PUBLIC_FORM_ID}
honeypotField={import.meta.env.PUBLIC_HONEYPOT_FIELD}
/>
The PUBLIC_ prefix is not protecting anything, because the form ID is rendered into the page either way. It is there so the value still resolves if the component is ever used inside one that hydrates, where a private variable is simply undefined.
Allow your origins
Every origin the form is served from needs to be on the form’s allowlist, scheme and host, no path. For Astro that is usually three:
- Name
http://localhost:4321- Type
- astro dev
- Description
The dev server’s default port. Worth adding if you test against the real endpoint rather than filling the console in by hand.
- Name
https://example.com- Type
- production
- Description
The apex and the
www.host are two different origins. Add whichever one actually serves the page, and both if both do.
- Name
your preview host- Type
- if you have one
- Description
A deploy preview is a third origin. If your host mints a fresh URL per deployment, allowlist a stable alias instead, or accept that previews will not submit.
A submission from an origin that is not listed is discarded and never stored, which is what stops someone who read your form ID out of your inbox. It is also the first thing to check when a new form appears to do nothing: see Responses and failure.
What happens when someone submits
The browser posts the form and navigates. If the form has a redirect path configured in the console, ezform answers 303 and the browser follows it with a GET, so a refresh cannot repost. Without one, ezform renders its own short confirmation page.
To keep the visitor on your page instead, post it with fetch from a client script and show your own message. This site’s own contact form does exactly that, and the whole of what a client can branch on is in the responses page: a non-2xx means the request never reached the handler, which in a browser means the origin is not allowed.
If the site uses <ClientRouter />, a <script> that binds a submit handler runs on the first
page load and not on the view transitions after it. Bind inside an astro:page-load listener, or
the form quietly stops submitting on every navigation except a full reload.
Where to go next
- Quickstart for the two values you need from the console
- Submitting a form for what the endpoint accepts and what it sends back
- Spam and origins for why the honeypot is hidden the way it is