RG Forms logoRG Forms
GitHub Pages

Adding a contact form to a GitHub Pages site

GitHub Pages is the strictest of the common static hosts. Netlify, Vercel and Cloudflare Pages all let you bolt on a function when you need one; Pages does not. It serves files. That’s the entire feature set, and it’s why it’s so reliable.

Which makes the contact form question simple: the endpoint lives elsewhere, full stop.

Last updated August 19, 2026

The short answer

GitHub Pages serves static files only — it runs no server-side code and offers no serverless functions — so a contact form there must POST to an endpoint hosted somewhere else. With RG Forms that endpoint is a Google Apps Script web app in your own Google account: your page sends a fetch() to it and each submission lands as a row in your own Google Sheet. Nothing about your repository or your Pages build changes, and the whole setup stays free.

What you can’t do on GitHub Pages

There’s no PHP, no Node process, no serverless runtime, and no way to handle a POST to your own domain. GitHub Actions can run code, but only at build time — it can’t receive a request from a visitor. The action="/contact" pattern from PHP-era tutorials has nowhere to land.

The only remaining options are an external form endpoint or a mailto: link, and mailto: quietly fails for any visitor without a configured desktop mail client.

The setup

  1. Create your endpoint

    Sign in to RG Forms with Google, name the project after your repo, and set your fields. You get back a permanent URL like https://script.google.com/macros/s/AKfycb.../exec.

  2. Authorize the script once

    Open that URL in your browser while signed in to Google and approve the permission screen. Until you do, the script can’t send notification emails.

  3. Add the form to your page

    Drop this into any page in your repo — index.html, a Jekyll layout, an include, wherever your contact section lives.

    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>
  4. Commit and push

    Pages rebuilds and the form is live. No workflow changes, no secrets to configure, no build plugin.

The endpoint URL is public — and that’s fine

Your repository is probably public, so anyone can read the endpoint URL out of your HTML. That’s expected: it has to be publicly callable for anonymous visitors to submit at all, exactly like every other form endpoint on the web.

What protects you is the honeypot and, if you want it, Cloudflare Turnstile verified inside your script before a row is written. What the URL doesn’t give anyone is access to your Google account — the script can only touch its own spreadsheet.

The endpoint only accepts submissions — it never returns them. A GET gets you {"ok":true} and nothing more, whatever parameters you add. So finding the URL in your source tells someone where to post, not what anyone has posted. Your submissions live in your Google Sheet, and who can see them there is down to how you’ve shared it — that part is yours to control, not something the endpoint decides.

Check it before you announce it

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

GitHub Pages specifics worth remembering

  • Custom domains work unchangedthe fetch is cross-origin either way, and the endpoint accepts requests from any origin.

  • No build secrets neededthe endpoint URL isn’t a credential, so there’s nothing to hide in Actions secrets — which is good, because Pages can’t inject env vars into static output anyway.

  • It survives repo transfersthe endpoint lives in your Google account, not the repo. Move the repo, change the org, rename the project — the form keeps working.

  • Project sites and user sites are identical herenothing in this depends on the path your site is served from.

Common questions

Can’t I use GitHub Actions to handle the submission?

No. Actions run on push, schedule, or other repository events — they can’t receive an HTTP request from a visitor’s browser. There’s the repository_dispatch API, but calling it requires a token, and putting a repo-write token in public client-side code is not something you want to do.

Does this work with GitHub Pages’ HTTPS?

Yes. Both your site and the endpoint are HTTPS, so there’s no mixed-content problem.

What about GitHub Issues as a form backend?

People do it, and it works, but it needs a token in client-side code or a proxy — and every submission becomes a public issue. For contact forms, a private spreadsheet is a better home.

Will this slow my Pages site down?

No. It’s about fifteen lines of inline JavaScript with no libraries, and no request is made until someone actually submits.

More answers in the full FAQ.

Your endpoint goes live in ~90 seconds