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.

Add your form →