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.

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.

Where to go next