Home / Guides

Add a contact form to a Hugo site

Published 2026-07-15

Hugo is fast because it does one thing: it renders Markdown into HTML at build time and gets out of the way. That's also why a contact form is the first thing that stops a Hugo site cold. There's no PHP, no Node process, no server-side anything sitting behind hugo server in production — just files on a CDN. When a visitor submits a <form>, there is nothing on the other end to catch it.

You don't fix this by adding JavaScript. You fix it by pointing the form's action at a URL that already knows how to receive a POST, store it, and notify you. That's the whole trick, and it needs exactly zero client-side script.

The form: plain HTML in a partial

Drop this in layouts/partials/contact-form.html (or inline it in a page template — a contact.md content page with a matching layouts/contact/single.html works too):

<form action="{{ .Site.Params.formEndpoint }}" method="POST">
  <label>Name
    <input type="text" name="name" required />
  </label>
  <label>Email
    <input type="email" name="email" required />
  </label>
  <label>Message
    <textarea name="message" required></textarea>
  </label>

  <!-- honeypot: hidden from real visitors, bots that fill it get silently dropped -->
  <input type="text" name="_honeypot" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" />

  <!-- send visitors to a thank-you page after a successful submit -->
  <input type="hidden" name="_redirect" value="{{ .Site.BaseURL }}thanks/" />

  <button type="submit">Send</button>
</form>

Include it wherever you need it:

{{ partial "contact-form.html" . }}

No fetch call, no onsubmit handler, nothing to hydrate. The browser does a normal form POST, the SubmissionBuddy endpoint replies with a 303 to your _redirect target, and the visitor lands on your thank-you page. If a visitor has JavaScript disabled, or you're testing with curl, it works exactly the same way.

Wiring the endpoint through site params

Hardcoding the form URL in a template works, but keeping it in hugo.toml means you can change forms (or point a staging build at a different form) without touching layout code:

# hugo.toml
[params]
  formEndpoint = "https://api.submissionbuddy.io/f/your-form"

(If you're on an older Hugo config using config.yaml or config.toml, the [params] block works the same way — Hugo just reads .Site.Params.formEndpoint regardless of which format you use.)

Sign up free at SubmissionBuddy, create a form, and you'll get a URL that looks like https://api.submissionbuddy.io/f/your-form. Drop it into formEndpoint and every {{ .Site.Params.formEndpoint }} reference across your templates picks it up.

There's no schema to define on the SubmissionBuddy side, either. Add, rename, or remove fields in your Hugo form template whenever you want — the endpoint accepts whatever field names you send, with no config change required on the form itself.

Spam protection

The _honeypot field in the form above is doing real work: it's a plain text input that's invisible to a human (positioned off-screen, not just display:none, since some bots skip visually-hidden-via-CSS fields — either approach is accepted here) but visible to a form-filling bot. If it arrives filled in, SubmissionBuddy drops the submission silently — no database row, no S3 write — while still returning a normal-looking success response, so the bot's script has no signal that anything went wrong.

Per-IP and per-form rate limiting is on by default on every plan, no configuration needed. If you get sophisticated abuse — proof-of-work solvers, headless-browser bots — paid plans add an invisible CAPTCHA that runs entirely server-side (no third-party script, no cookies) and challenges only suspicious traffic, not every visitor.

The thank-you page

_redirect needs a real page to land on. In Hugo, that's just another content file:

<!-- content/thanks.md -->
---
title: "Thanks"
---

Thanks for reaching out — we'll get back to you shortly.

With the default archetype and a matching layouts/thanks/single.html (or reuse your single.html for pages), hugo builds this to /thanks/index.html, which is the URL you pointed _redirect at above.

What happens after the visitor hits submit

The raw submission is written to durable storage before SubmissionBuddy sends back any response at all — nothing touches the data until it's safely stored. From there it moves through independent, retryable stages: a database write that makes it searchable in your dashboard, then an email notification to your team. If the email step hiccups, it retries automatically; it doesn't touch the already-stored submission.

The honest pitch

Free plan: 100 submissions a month, 2 forms, no credit card. That's enough for most personal sites, portfolios, and small project pages built with Hugo. If you outgrow it, paid plans start at $8/month. There's no client library to install for any of this — it's an HTML action attribute and a config value.

Stop losing form submissions

SubmissionBuddy stores every submission durably before anything else touches it — 100 free submissions/month, no credit card, live in five minutes.

Start free