Hugo contact form with a reusable partial
A Hugo contact form is a partial plus a shortcode. Hugo builds static files, so the form posts to an endpoint somewhere else, and the Hugo-specific work is making sure you write it once.
layouts/_partials/contact-form.html:
{{ $label := .label | default "Send" }}
<form action="{{ site.Params.formEndpoint }}" method="POST">
<label>
Email
<input name="email" type="email" required>
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button>{{ $label }}</button>
</form>
Written against Hugo v0.15x. The template directories were renamed in Hugo v0.146.0: layouts/partials/ and layouts/shortcodes/ became layouts/_partials/ and layouts/_shortcodes/. Both spellings still resolve, so an older site keeps working, but new scaffolding uses the underscore form.
Always pass the partial a dict, never the page
This is the detail that decides whether the site builds. Call the partial with the page context and Hugo aborts:
{{ partial "contact-form.html" . }}
execute of template failed: template: _partials/contact-form.html:
executing "_partials/contact-form.html" at <.label>:
can't evaluate field label in type *hugolib.pageState
Go’s template engine does not return an empty value for a missing struct field, it raises a hard error, and a page is a struct. A dict is a map, and a map returns the zero value for a key it does not have, which is exactly what default needs:
{{ partial "contact-form.html" (dict "label" "Send") }}
{{ partial "contact-form.html" (dict "label" "Request a quote") }}
So one partial serves every form on the site, and the rule is simply that it always receives a map.
A shortcode puts the form inside markdown
A partial only reaches layouts. Most of the time the form belongs in a page written in markdown, and that is what a shortcode is for. Forward the shortcode’s own parameters into the dict so the label survives the trip:
layouts/_shortcodes/contact-form.html:
{{ partial "contact-form.html" (dict "label" (.Get "label")) }}
Now any file under content/ can place it, with or without an override:
Get in touch and we will reply within a day.
{{< contact-form >}}
{{< contact-form label="Request a quote" >}}
.Get returns an empty string when the parameter is absent, which default turns back into “Send”.
The endpoint belongs in hugo.toml, not the template
[params]
formEndpoint = "https://api.formroute.dev/f/site_xyz"
site.Params is the current global. Older templates use .Site.Params, which still resolves but ties the partial to whatever context called it, and that is the same trap as above.
This is the step people skip. Cloning a Hugo site for another project is common, and with the URL in config the endpoint changes in one file instead of in every template that happens to contain a form.
Where the visitor lands after submitting
A native POST navigates, and a static build has no route to redirect to unless you made one. Add content/thanks.md and point the endpoint at it, or intercept the submit with the script in the vanilla JavaScript contact form and never leave the page.
Hugo has no runtime either, so required and type="email" are courtesies to the honest visitor and the rules have to hold at the endpoint instead. What a form backend does with them covers that half.
If you build with Hugo and deploy through Actions, the GitHub Pages contact form covers what that host runs. If you are weighing generators, the Astro contact form solves the same problem with a component.
The partial is the only file that changes when you clone this site for the next project. FormRoute is what catches what it sends, and access is private for now: request an invite.