Hugo contact form without a backend
Formgong team ·
A Hugo contact form is a partial that renders a plain HTML form posting to Formgong, plus a shortcode for Markdown pages. It works on any static host, including GitHub Pages, Netlify, and Cloudflare Pages. Telegram is instant. On the free plan, email is a next-morning digest at 08:00.
Partial for the contact form
Put the form in layouts/_partials/contact-form.html. Call it with partial and pass the dot.
Hugo 0.146 renamed layouts/partials to layouts/_partials, and layouts/shortcodes to layouts/_shortcodes. The template-system page says to rename those folders.
Read the key from site.Params.formgongKey. That is your own site param, not a theme setting. The key is public, so the HTML may contain it.
A site param is only a convenience. You can also paste YOUR_ACCESS_KEY straight into the partial.
Free stores 300 submissions a month. One verified address gets the daily digest.
The digest goes out the next morning at 08:00 in the owner's time zone. It covers the previous day.
Telegram is instant on every plan, including Free. There is no auto-reply on Free.
Pro and Business email each submission. Pro allows 3 recipients. Business allows 5.
{{/* layouts/_partials/contact-form.html */}}
{{/* Hugo 0.146 renamed layouts/partials to layouts/_partials. */}}
<form action="https://formgong.com/submit" method="POST">
<input type="hidden" name="access_key" value="{{ site.Params.formgongKey }}">
<input type="hidden" name="_lang" value="{{ site.Language.Name }}">
<input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}">
<label for="name">Name
<input id="name" type="text" name="name" autocomplete="name" required>
</label>
<label for="email">Email
<input id="email" type="email" name="email" autocomplete="email" required>
</label>
<label for="message">Message
<textarea id="message" name="message" required></textarea>
</label>
<div aria-hidden="true" style="position:absolute;inset-inline-start:0;top:0;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">
<label>Leave this empty
<input type="text" name="botcheck" tabindex="-1" autocomplete="off">
</label>
</div>
<button type="submit">Send</button>
</form>hugo.toml param
# hugo.toml baseURL = "https://example.com/" [params] formgongKey = "YOUR_ACCESS_KEY"
Shortcode on a Markdown page
A shortcode lets a Markdown page include the form without a custom layout.
Save layouts/_shortcodes/contact.html. It renders the same partial.
In content/contact.md, call the shortcode. Hugo's shortcode page shows the tag form with angle brackets.
The page stays Markdown. Hugo still writes a static HTML file. The browser posts the form.
layouts/_shortcodes/contact.html
{{/* layouts/_shortcodes/contact.html */}}
{{/* Older Hugo used layouts/shortcodes/contact.html. */}}
{{ partial "contact-form.html" . }}content/contact.md
---
title: Contact
---
Write your intro here.
{{< contact >}}Thank-you page with absURL
absURL turns thanks/ into an absolute URL from baseURL. Do not start the path with a slash.
Hugo's absURL page says a leading slash ignores the path of baseURL. A project site under /docs/ would then miss the folder.
The thank-you page must be on the same site as the form, or on a host you allowed. An off-site URL is dropped.
With no _redirect, Formgong shows its own success page. A fetch script can stay on the contact page instead.
The pattern is the same as a thank-you page after form submission.
Multilingual Hugo
site.Language.Name is the language tag from your config, such as en or de. The docs added Name in 0.153 and marked Lang deprecated in 0.158.
Older sites can still print site.Language.Lang. It is the same tag. New templates should use Name.
Put that tag in _lang so the error text and the success text match the page.
For the thank-you URL, the absURL page says to use absLangURL on a multilingual site. It keeps the language prefix.
A German page then opens the German thanks page, and the visitor still sees German errors if the post fails.
Language-aware redirect
<input type="hidden" name="_lang" value="{{ site.Language.Name }}">
<input type="hidden" name="_redirect" value="{{ "thanks/" | absLangURL }}">Blowfish and PaperMod
Neither theme needs a special contact-form key. Do not invent one. Put the form in your project, not in the theme folder.
Blowfish's advanced customisation page, checked on 6 October 2026, says never edit files under the theme. A project file wins Hugo's lookup.
Their example for a page template is layouts/_default/single.html. Their custom homepage is homepage.layout set to custom, plus layouts/partials/home/custom.html.
Hugo 0.146 moved _default files up into layouts, and renamed partials to _partials. If a Blowfish upgrade still documents the old path, follow the theme page you have, and keep the form partial in the project.
PaperMod's FAQ, edited 26 January 2024, says to copy a theme template into your site layouts folder. Do not edit the copy inside themes.
That FAQ also documents layouts/partials/extend_head.html and extend_footer.html for extra head and footer HTML. A contact form does not belong in the footer.
On both themes, content/contact.md plus the shortcode is enough. You do not override a layout unless the theme's page wrapper gets in the way.
Stay on the page
Add id hugo-contact to the form and a status paragraph. The script below calls preventDefault and posts FormData.
Do not set Content-Type. Send Accept application/json so the response is JSON instead of a redirect.
The plain form still works if JavaScript is off. That is the path GitHub Pages, Netlify and Cloudflare Pages all serve.
Inline success with fetch
<script>
const form = document.querySelector("#hugo-contact");
const status = form.querySelector("[role=status]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
const button = form.querySelector("button");
button.disabled = true;
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
const ok = response.ok && result.success;
status.textContent = result.message || (ok ? "Thanks. We have your message." : "Could not send. Try again.");
status.setAttribute("role", ok ? "status" : "alert");
if (ok) form.reset();
} catch {
status.textContent = "Could not send. Check your connection and try again.";
status.setAttribute("role", "alert");
} finally {
button.disabled = false;
}
});
</script>When the build hides the form
Hugo writes the key into the HTML at build time. Change hugo.toml, then rebuild. A live reload of an old public folder still has the old key.
baseURL should end with a slash. absURL joins that base with thanks/. A missing slash glues the path to the host name.
Do not write /thanks/ with a leading slash. Hugo's absURL page says a leading slash drops the path in baseURL.
The partial call is partial "contact-form.html" and the dot. A file left in the old layouts/partials folder is invisible after 0.146.
On a multilingual site, absLangURL keeps the language folder. absURL alone can open the default-language thanks page.
A project site on GitHub Pages needs baseURL to include the repository path. Otherwise the thank-you link leaves the project.
If the inbox stays empty, open the built HTML and check that access_key is the real key, not an empty param.
Error states
Treat the send as a success only when the HTTP status is OK and success is true.
Read code and message from the JSON. The message is already translated for _lang.
Leave botcheck empty. The same trap also answers to _gotcha, _honey and _honeypot.
A filled honeypot is stored as spam. It is not emailed, it is not sent to Telegram, and it does not use the monthly quota.
If the page sends _ts and the form arrives in under 1.5 seconds, that lead is scored as spam too.
Turnstile is optional on every plan. It is required when a file is attached. The secret stays in Formgong.
| Code | What it means | What to do |
|---|---|---|
| rate_limited | 429. More than 30 posts a minute for this form, or 20 from one IP. | Wait a minute. Show the message and leave the fields filled. |
| limit_exceeded | The account used its monthly submissions. Free is 300. | The lead is not delivered. Wait until next month, or move to a paid plan. |
| unknown_access_key | 404. That access key does not match a form. | Copy the key again from Formgong. A placeholder key always fails. |
| turnstile_missing | Turnstile is on, or a file is attached, and the token was not posted. | Add the widget only after you turn protection on. The field name is cf-turnstile-response. |
| turnstile_failed | Cloudflare rejected the token. | Load the widget again and submit once. Check the site key. |
| too_large | 413. The text body is over 64 KB, or a file is over the upload cap. | Shorten the message, or send fewer files. The cap is 3 files, 5 MiB each, 10 MiB total. |
| origin_not_allowed | A Pro domain list is on, and this host is not in it. | Add the live host. Free has no domain list. |
The same HTML form is on the HTML contact form. Jekyll and Eleventy notes are on Jekyll and Eleventy. GitHub Pages host notes are in the GitHub Pages contact form article. Quotas are on the pricing page.
How forms work in Hugo
Hugo generates static HTML. There is no server-side form handler in a normal Hugo deploy to GitHub Pages, Netlify or Cloudflare Pages.
Put a form partial in your layout, set action to Formgong, and build as usual. The approach matches any static site; see also /for/html and the GitHub Pages article.
Set it up in Hugo
- Create a Formgong form and copy the access key.
- Add a partial (for example
layouts/partials/contact-form.html) with the HTML form,access_key,_langandbotcheck. - Include the partial on your contact page. Use an absolute thank-you URL in
_redirectif you want one ({{ site.BaseURL }}…). - Build and deploy. Send a test from the live URL. The lead is in the inbox. Telegram is instant. On Free, email is the next-morning digest at 08:00.
Formgong or a Hugo serverless function?
Add a Netlify/Cloudflare function when you must run custom server logic on submit.
Use Formgong when you only need delivery and an inbox without writing that function.
Questions and answers
Does Hugo need a form plugin?
No. Hugo outputs HTML; the browser posts to Formgong.
Can I use a shortcode?
Yes. A shortcode that renders the same form markup works. The access key is public, so it is fine in the HTML. A Hugo site param is only a convenience.
Is this the same as GitHub Pages?
Same idea. The GitHub Pages blog post covers host-specific notes; this page is Hugo-oriented.
Articles on this topic
Related pages
Guides
- Formgong for agencies
- Static website contact form
- Netlify Forms alternative
- GDPR-compliant contact form
- Formspree alternatives
- Web3Forms alternatives
- EmailJS alternatives
- Formspree vs Web3Forms
- Formspree vs EmailJS
- Best free form backends
- Free website templates
- Testimonial and rating widget
- Craftline: plumber website template
- Orrery: consulting website template
- Lattice: portfolio website template
- Lattice Estate: home builder website template
- Shoal: real estate agent website template
- Lattice Atelier: windows and doors website template
- Soglia: designer windows website template
- Halftone: architecture studio website template
- Ocra: shop and maker website template
- Arcwell: interior design studio website template
- AI builder prompts
- Form checker
- Agent rules files
- Tools