RG Forms logoRG Forms
Tutorial

How to add a working contact form to a static website

This is the complete version — every step, in order, including the two that get skipped and cause every “my contact form doesn’t work” support thread.

Last updated August 19, 2026

The short answer

To add a working contact form to a static website: create a hosted endpoint to receive submissions, add a plain HTML form to your page, submit it with JavaScript's fetch() instead of a normal form post, and show sending, success and error states. With RG Forms the endpoint is a Google Apps Script web app provisioned into your own Google account — sign in with Google, define your fields, authorize the script once, then POST to the endpoint URL with Content-Type: text/plain and a body of { tab, fields }. Each submission appends a row to your Google Sheet and optionally emails you. The whole process takes about ten minutes including testing.

The walkthrough

  1. Create the endpoint

    Sign in to RG Forms with Google. Name the project after your site and define your fields — name, email, message is the usual starting set. Turn on email notifications now if you want them: adding the capability later means re-authorizing the script, whereas enabling it up front costs nothing (no email sends until you set an address).

  2. Wait for provisioning

    RG Forms creates a Drive folder, a Google Sheet with a header row matching your fields, and an Apps Script web app deployed at a permanent URL. About ninety seconds. Copy the endpoint URL — it looks like https://script.google.com/macros/s/AKfycb.../exec and never changes, even when you edit fields later.

  3. Authorize the script — don’t skip this

    Open the endpoint URL in your browser while signed in to Google and approve the permission dialog. Google will warn that the app isn’t verified: the “app” is *your own script*, created minutes ago in your own account, which is exactly why it has no verification. Click Advanced, then Go to <project> (unsafe), then Allow. Miss this step and notifications silently never send.

  4. Prove the endpoint works before touching your site

    Ten seconds now, or an hour of guessing later. If a row appears in your Sheet, the backend half is finished and any problem after this point is in your page.

    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
  5. Add the form to your page

    Paste this in, replace the endpoint URL, and make sure each input’s name matches your field keys — the key is your field label lowercased with non-alphanumeric characters turned into underscores, so “Company Name” becomes company_name.

    index.html
    <form id="contact-form">
      <label>Name <input name="name" required /></label>
      <label>Email <input type="email" name="email" required /></label>
      <label>Message <textarea name="message" required></textarea></label>
    
      <!-- Honeypot: humans never see it, bots fill it in -->
      <input type="text" name="_hp" tabindex="-1" autocomplete="off"
             style="position:absolute;left:-9999px" aria-hidden="true" />
    
      <button type="submit">Send</button>
      <p id="form-status" role="status"></p>
    </form>
    
    <script>
      const ENDPOINT = "https://script.google.com/macros/s/AKfycb.../exec";
      const form = document.getElementById("contact-form");
      const status = document.getElementById("form-status");
    
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        status.textContent = "Sending…";
    
        const res = await fetch(ENDPOINT, {
          method: "POST",
          // text/plain avoids the CORS preflight Apps Script can't answer
          headers: { "Content-Type": "text/plain" },
          body: JSON.stringify({
            tab: "contact",
            fields: Object.fromEntries(new FormData(form)),
          }),
        });
    
        const data = await res.json();
        status.textContent =
          data.result === "success" ? "Thanks — we'll be in touch." : "Something went wrong.";
        if (data.result === "success") form.reset();
      });
    </script>
  6. Handle the three states properly

    Sending — disable the button and say so, or people double-click and you get duplicate rows. Success — reset the form and confirm clearly. Error — show a real message with a fallback email address, so a failed submission never dead-ends. The snippet above covers the basics; expand the error path with a try/catch around the fetch for network failures.

  7. Block the bots

    The hidden _hp honeypot in the snippet is already doing work — anything that arrives with it filled in is silently dropped. If you start seeing spam anyway, add Cloudflare Turnstile: put the widget in your form, send its token as _captcha, then switch verification on in your RG Forms dashboard. In that order — turning verification on before the widget is live would reject real submissions.

  8. Test on the deployed site

    Not locally — on the real URL, on a phone as well as a desktop. Confirm the row lands in the Sheet and the notification reaches your inbox (check spam once). Then send one more a day later, because a form that works on launch day and not on Tuesday is the worst possible outcome.

Pre-launch checklist

  • Submitted a real message from the deployed siteand confirmed it arrived in both the Sheet and the inbox.

  • Tested on a phonethe majority of contact form submissions on most sites are mobile.

  • Every input has a real `<label>`not just a placeholder — better for screen readers, and it improves completion.

  • The error state names a fallback email addressso the visitor always has another route to you.

  • Checked the spam folder for the notificationonce, deliberately, before you rely on it.

  • Know your notification limit~100 recipients/day on free Gmail, ~1,500 on Workspace. Rows still save past it.

Framework-specific versions

The code above is plain HTML and works anywhere. If you’re on a framework, these are the idiomatic versions: Astro, React, Hugo, Jekyll. By host: GitHub Pages, Netlify, Cloudflare Pages, Vercel.

Common questions

How long does this take?

About ten minutes end to end — two for the endpoint, five for the markup and states, three for testing properly.

Do I need to know JavaScript?

You need to paste it. The snippet is complete and unmodified for most sites; the only thing you have to change is the endpoint URL and the field names.

My form submits but nothing appears in the sheet. What now?

Run the curl test. If that writes a row, the endpoint is fine and the problem is in your page — check the browser console for a CORS error (wrong Content-Type) and confirm your tab value matches your tab name exactly.

Can I have a separate form on another page?

Yes. Add a form in your dashboard — it becomes another tab in the same Sheet — and change the tab value in the request body. Same endpoint URL for all of them.

More answers in the full FAQ.

Your endpoint goes live in ~90 seconds