Adding a contact form to a Hugo site
July 23, 2026
Hugo builds your site in milliseconds because it doesn't run a server. That's great until you need a contact form. The usual advice is "add a serverless function" (congratulations, now you have two deploy targets) or "use mailto" (please don't). There's a third option: point plain HTML at a form backend and stay 100% static.
1. Get an endpoint
Create a NormForms form. You'll
get a URL like https://normforms.com/f/x7Kp2mQ9RtLw.
2. Make it a partial
Create layouts/partials/contact-form.html:
<form action="https://normforms.com/f/YOUR_TOKEN" method="POST"> <label for="nf-name">Name</label> <input id="nf-name" type="text" name="name" autocomplete="name" required> <label for="nf-email">Email</label> <input id="nf-email" type="email" name="email" autocomplete="email" required> <label for="nf-message">Message</label> <textarea id="nf-message" name="message" required></textarea> <input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off"> <button type="submit">Send</button> </form>
Then drop it in wherever you want the form to show up:
{{ partial "contact-form.html" . }}
Want the token out of your templates? Put it in your site config
(params.normformsToken in hugo.toml) and
interpolate it in the partial. Handy if you're running different forms
per environment.
3. Test locally
Submissions from hugo server on localhost land in test mode
automatically: a [Test] subject prefix, and none of it
touches your quota. Once you're live, set your domain in the form's
settings and anything from anywhere else gets silently rejected.
The part you don't build
Spam filtering (honeypot, origin lock, rate limits, invisible challenge), email delivery with proper authentication, and a dashboard that stores every message with its delivery status. Your Hugo build stays exactly what it already is: fast, static, and serverless in the original sense of the word.