RG Forms logoRG Forms
Hugo

Adding a contact form to a Hugo site

Hugo builds fast and deploys anywhere, which is exactly why the contact form is the one thing that doesn’t fit: there’s no server in the picture at any point.

The tidy solution is a partial with the endpoint in site config, so the form is themeable, reusable and has no URL hardcoded in your templates.

Last updated August 19, 2026

The short answer

Hugo generates static HTML with no server behind it, so a contact form has to POST to an external endpoint. The clean pattern is a partial in layouts/partials/contact-form.html that reads the endpoint URL from a site parameter in hugo.toml, plus a thin shortcode so content authors can drop the form into any Markdown page. With RG Forms the endpoint is a Google Apps Script web app in your own Google account and submissions land in your own Google Sheet — so the form works on GitHub Pages, Netlify, Cloudflare Pages or any other host without changes.

The setup

  1. Put the endpoint in site config

    Keeping it in config means a single place to change it, and it’s available to every template through .Site.Params.

    hugo.toml
    [params]
      formEndpoint = "https://script.google.com/macros/s/AKfycb.../exec"
      contactFallbackEmail = "hello@example.com"
  2. Create the partial

    This is the whole form — markup, behaviour and the honeypot, in one file.

    layouts/partials/contact-form.html
    {{ $endpoint := .Site.Params.formEndpoint }}
    {{ $fallback := .Site.Params.contactFallbackEmail }}
    
    <form id="contact-form" class="contact-form" data-endpoint="{{ $endpoint }}">
      <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>
    
      <input type="text" name="_hp" tabindex="-1" autocomplete="off"
             aria-hidden="true" style="position:absolute;left:-9999px">
    
      <button type="submit">Send message</button>
      <p id="form-status" role="status" aria-live="polite"></p>
    </form>
    
    <script>
      (function () {
        var form = document.getElementById('contact-form');
        var status = document.getElementById('form-status');
        var button = form.querySelector('button');
        var endpoint = form.dataset.endpoint;
    
        form.addEventListener('submit', function (event) {
          event.preventDefault();
          button.disabled = true;
          status.textContent = 'Sending\u2026';
    
          fetch(endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'text/plain' },
            body: JSON.stringify({
              tab: 'contact',
              fields: Object.fromEntries(new FormData(form))
            })
          })
            .then(function (res) { return res.json(); })
            .then(function (data) {
              if (data.result === 'success') {
                form.reset();
                status.textContent = 'Thanks \u2014 we will be in touch.';
              } else {
                status.textContent = 'Something went wrong. Please try again.';
              }
            })
            .catch(function () {
              status.textContent = 'Network error. Email us at {{ $fallback }}.';
            })
            .finally(function () { button.disabled = false; });
        });
      })();
    </script>
  3. Add a shortcode so Markdown pages can use it

    One line, so authors don’t need to touch templates.

    layouts/shortcodes/contact-form.html
    {{ partial "contact-form.html" . }}
  4. Use it in content

    In content/contact.md, or in a template with {{ partial "contact-form.html" . }}.

    content/contact.md
    ---
    title: "Contact"
    ---
    
    Questions about a project? Send a note and we'll reply within a day.
    
    {{< contact-form >}}

Why not Hugo’s own form handling?

There isn’t any — and that’s not a gap in Hugo, it’s the design. Hugo is a static site generator: it renders HTML at build time and stops. Nothing is running when a visitor clicks Submit.

So every Hugo contact form is an external endpoint. The only question is whose, and where the data ends up.

Verify it before you push

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

Common questions

Will this work with any Hugo theme?

Yes. It’s a partial you own, in your own layouts/ directory, which overrides the theme. Style it with your theme’s classes and it will look native.

Can I put the JavaScript in my asset pipeline instead?

Yes — move the script body into assets/js/contact-form.js, process it with resources.Get and js.Build, and keep only the data-endpoint attribute in the markup. The inline version is here so the partial is one self-contained file.

How do I add a second form, like a newsletter signup?

Add the form in your RG Forms dashboard (it becomes a new tab in the same Sheet), then parameterise the partial with a tab argument and pass it through from the shortcode.

Does this work with `hugo server` locally?

Yes. The fetch goes to a public HTTPS endpoint that accepts any origin, so local development submits for real — useful for testing, and a reason to use a separate test project while you build.

More answers in the full FAQ.

Your endpoint goes live in ~90 seconds