RG Forms logoRG Forms
Astro

Adding a contact form to an Astro site

Astro’s whole proposition is shipping less: static HTML, JavaScript only where you ask for it. Adding an SSR adapter and a server runtime just to handle a contact form undoes a good part of that.

You don’t need to. Here’s the version that keeps output: 'static'.

Last updated August 19, 2026

The short answer

You can add a working contact form to a static Astro site without switching to SSR or installing an adapter. Keep output static, put the form in a .astro component with a client-side <script> that POSTs to a hosted endpoint, and pass the endpoint URL in with define:vars from PUBLIC_FORM_ENDPOINT. With RG Forms that endpoint is a Google Apps Script web app in your own Google account, so submissions land in your own Google Sheet and the build stays a pure static output you can deploy anywhere.

The component

Save this as src/components/ContactForm.astro and use it anywhere. No framework island, no React, no client directive.

src/components/ContactForm.astro
---
const endpoint = import.meta.env.PUBLIC_FORM_ENDPOINT;
---

<form id="contact-form" class="contact-form">
  <label>
    Name
    <input name="name" required />
  </label>
  <label>
    Email
    <input type="email" name="email" required />
  </label>
  <label>
    Message
    <textarea name="message" rows="5" required></textarea>
  </label>

  <!-- honeypot: hidden from humans, irresistible to bots -->
  <input type="text" name="_hp" tabindex="-1" autocomplete="off" aria-hidden="true" />

  <button type="submit">Send message</button>
  <p id="form-status" role="status" aria-live="polite"></p>
</form>

<script define:vars={{ endpoint }}>
  const form = document.getElementById('contact-form');
  const status = document.getElementById('form-status');
  const button = form.querySelector('button');

  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    button.disabled = true;
    status.textContent = 'Sending…';

    try {
      const res = await fetch(endpoint, {
        method: 'POST',
        // text/plain keeps this a "simple request" — no CORS preflight
        headers: { 'Content-Type': 'text/plain' },
        body: JSON.stringify({
          tab: 'contact',
          fields: Object.fromEntries(new FormData(form)),
        }),
      });
      const data = await res.json();

      if (data.result === 'success') {
        form.reset();
        status.textContent = 'Thanks — we’ll be in touch.';
      } else {
        status.textContent = 'Something went wrong. Please try again.';
      }
    } catch {
      status.textContent = 'Network error. Email us at hello@example.com.';
    } finally {
      button.disabled = false;
    }
  });
</script>

<style>
  .contact-form { display: grid; gap: 0.75rem; max-width: 32rem; }
  .contact-form label { display: grid; gap: 0.25rem; }
  .contact-form input[name='_hp'] { position: absolute; left: -9999px; }
</style>

define:vars is the important detail — Astro bundles <script> tags separately from the component frontmatter, so a value from import.meta.env won’t be in scope inside the script unless you pass it through explicitly.

Setting it up

  1. Create your endpoint

    Sign in to RG Forms with Google and define fields matching your form’s input names: name, email, message. You get a permanent endpoint URL back.

  2. Add the env var

    Astro only exposes variables prefixed with PUBLIC_ to client code — which is what you want here, since the fetch runs in the browser.

    .env
    PUBLIC_FORM_ENDPOINT="https://script.google.com/macros/s/AKfycb.../exec"
  3. Drop the component in a page

    Import and render it — nothing else to configure.

    src/pages/contact.astro
    ---
    import Layout from '../layouts/Layout.astro';
    import ContactForm from '../components/ContactForm.astro';
    ---
    
    <Layout title="Contact us">
      <h1>Get in touch</h1>
      <ContactForm />
    </Layout>
  4. Test the real path

    Run astro dev and submit. Unlike host-native form features, this works identically in dev, in preview builds and in production — there’s no build-time detection involved.

    Test the endpoint from your terminal
    curl -L -X POST "https://script.google.com/macros/s/AKfycb.../exec" \
      -H "Content-Type: text/plain" \
      -d '{"tab":"contact","fields":{"name":"Ada","email":"ada@example.com","message":"Hello"}}'
    
    # → {"result":"success"}   and a new row appears in your Google Sheet

Multiple forms, one endpoint

A newsletter signup in the footer and a contact form on /contact don’t need two projects. Add a second form in your RG Forms dashboard — it becomes another tab in the same Sheet — and change the tab value in the request body. Same endpoint URL, same component, one prop different.

Make tab a prop on the component and you have a reusable form for the whole site.

Common questions

Do I need an SSR adapter?

No. output: "static" is all you need — the submission happens in the browser after the page is served, so nothing runs on a server at request time.

Where should the endpoint URL live?

A PUBLIC_ env var, for convenience rather than secrecy. The URL ships in the client bundle either way; it has to be publicly callable for anonymous visitors to submit at all.

Can I use Astro Actions?

Actions require a server, which means an adapter and a hosting runtime. For a contact form that’s a lot of machinery to add — and you’d still need somewhere to store the data and a way to send email.

Does the honeypot need styling in every theme?

The scoped <style> block above handles it. Keep it positioned off-screen rather than display: none — some bots skip fields that are explicitly hidden.

More answers in the full FAQ.

Your endpoint goes live in ~90 seconds