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
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"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>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" . }}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
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 SheetCommon 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.