Astro

Add a contact form to an Astro site: a plain HTML form in a .astro component, or a script-enhanced version that stays on the page.

Astro sends zero JavaScript by default, and a contact form does not need any: a plain HTML form posting to SilentForms is enough, and it works with Astro's default static output. When you want visitors to stay on the page, a small <script> in the same component adds that, without a UI framework or an island.

What you'll build#

A ContactForm.astro component that:

  • renders a complete HTML form at build time, with the key from import.meta.env,
  • redirects to your own thank-you page when JavaScript is off,
  • is enhanced by a bundled <script> that submits with fetch and shows the result inline.

1. Add the key#

Get a key on the access key page and add it to .env in the project root:

PUBLIC_SILENTFORMS_KEY=YOUR_ACCESS_KEY_HERE

In component frontmatter, Astro can read any variable from import.meta.env. The PUBLIC_ prefix is what allows client-side code (scripts and framework islands) to read it as well, and it marks the value as safe to publish, which the key is: it only routes submissions to your inbox. With static output the value is read at build time, so set the same variable in your host's build settings.

If you declare variables with the astro:env schema, define this one with context: "client" and access: "public" and import it from astro:env/client instead.

2. The component#

Create src/components/ContactForm.astro:

---
interface Props {
  redirect?: string;
}
const { redirect = new URL('/thanks/', Astro.site ?? 'https://example.com').href } = Astro.props;
const accessKey = import.meta.env.PUBLIC_SILENTFORMS_KEY;
---

<form class="contact-form" action="https://silentforms.com/api/submit" method="POST" data-contact-form>
  <input type="hidden" name="accessKey" value={accessKey} />
  <input type="hidden" name="redirect" value={redirect} />

  <label for="cf-name">Name</label>
  <input id="cf-name" name="name" type="text" autocomplete="name" required />

  <label for="cf-email">Email</label>
  <input id="cf-email" name="email" type="email" autocomplete="email" required />

  <label for="cf-message">Message</label>
  <textarea id="cf-message" name="message" rows="5" required></textarea>

  <input type="checkbox" name="botcheck" style="display: none;" tabindex="-1" autocomplete="off" />

  <button type="submit">Send message</button>
  <p role="status" data-status></p>
</form>

<script>
  for (const form of document.querySelectorAll<HTMLFormElement>('[data-contact-form]')) {
    const button = form.querySelector('button')!;
    const status = form.querySelector<HTMLElement>('[data-status]')!;

    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      button.disabled = true;
      status.setAttribute('role', 'status');
      status.textContent = 'Sending…';
      try {
        const response = await fetch(form.action, {
          method: 'POST',
          headers: { Accept: 'application/json' },
          body: new FormData(form),
        });
        const result = await response.json();
        if (!response.ok) throw new Error(result.message);
        form.reset();
        status.textContent = 'Thanks! Your message has been sent.';
      } catch (error) {
        status.setAttribute('role', 'alert');
        status.textContent = (error as Error).message || 'Something went wrong. Please try again.';
      } finally {
        button.disabled = false;
      }
    });
  }
</script>

<style>
  .contact-form {
    display: grid;
    gap: 0.5rem;
    max-width: 32rem;
  }
</style>

Set site in astro.config.mjs (for example site: 'https://example.com') so Astro.site gives the redirect your real domain. SilentForms only accepts an absolute URL for redirect.

Use the component on a page, src/pages/contact.astro:

---
import ContactForm from '../components/ContactForm.astro';
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Contact</title>
  </head>
  <body>
    <h1>Contact us</h1>
    <ContactForm />
  </body>
</html>

and add the page the no-JS path lands on, src/pages/thanks.astro, with a short "Thanks, your message has been sent" message.

How it works#

Without JavaScript, the browser posts the form to SilentForms and is redirected to /thanks/. With JavaScript, the script cancels the navigation and sends the same FormData with Accept: application/json; SilentForms answers with JSON and ignores the redirect field. Both paths share one set of fields, and the key appears once, in the hidden input.

Astro-specific details#

  • Scripts are processed and bundled. Astro turns a <script> in a component into a deduplicated module, so it runs once per page even if the component is used several times. That is why the script loops over every [data-contact-form] element instead of using an id. Module scripts are deferred, so the form is already in the DOM when it runs.
  • TypeScript works in the script. The as Error and generic querySelectorAll above are stripped at build time. Remove them if your project uses plain JavaScript.
  • View transitions. If your layout uses Astro's <ClientRouter />, bundled scripts do not re-run after a client-side navigation. Wrap the loop in document.addEventListener('astro:page-load', () => { ... }) so forms on newly loaded pages are enhanced too.
  • Static output. Nothing here needs an adapter or on-demand rendering. astro build writes plain HTML to dist/, ready for any static host.

Using a framework island instead#

If the form is part of a larger React, Vue or Svelte UI, build it as a component in that framework and render it as an island, for example <ContactForm client:visible />. The React, Vue and Svelte guides have ready-made components; read the key there as import.meta.env.PUBLIC_SILENTFORMS_KEY, since Astro uses the PUBLIC_ prefix rather than VITE_. For a plain contact form, the .astro version above is lighter: it ships no framework runtime.

Testing#

  1. Run astro dev, open /contact and submit. The status line should read "Thanks! Your message has been sent." and the email should arrive.
  2. Disable JavaScript and submit again: you should land on /thanks/.
  3. Run astro build && astro preview. A response of "Access key is required." means PUBLIC_SILENTFORMS_KEY was not set at build time. If the no-JS test lands on example.com, site is missing from astro.config.mjs and the fallback domain is still in place.

Next steps#