Why Hugo can't handle forms by itself
Hugo is a static site generator. You write markdown and templates, run hugo, and the output is plain HTML, CSS, and JS files that any web server can serve. That's the whole appeal — no PHP, no Node process, no database, no attack surface. The trade-off is the one you're hitting right now: there is no server to receive a form POST after the build.
People hit this wall and reach for one of three options. They glue a serverless function onto their static host. They use Netlify Forms because it's bundled with the host. Or they use a form-to-email backend like splitforms, Formspree, or Web3Forms. The third option is the only one that keeps your Hugo project portable across hosts, doesn't require writing server code, and works the day you build it.
This guide assumes you have a working Hugo site (any theme, any version 0.110+), you want a contact form that emails submissions to your inbox, and you don't want to write Go, Node, or any server-side handler. By the end you'll have a reusable shortcode you can drop into any markdown file with one line. If you're picking a backend, the 2026 best free form backend roundup compares the options.
One last piece of context before we dig in. Most Hugo tutorials online were written in 2018-2021, when Netlify Forms felt like the obvious default and Formspree was the only well-known alternative. Both are still around, but the landscape changed. Cloudflare Pages and Vercel now host most new Hugo sites, neither has a built-in form handler, and the cost gap between bundled form services and modern form-to-email backends widened a lot. So the right answer in 2026 looks different from the right answer in 2019, and a fresh walkthrough is worth your five minutes.
Why Netlify Forms is the wrong default
Netlify Forms is the most-recommended option in old Hugo tutorials because it looks effortless: add data-netlify="true" to your form tag and Netlify's build pipeline does the rest. The problem is that the magic only happens on Netlify. The moment you redeploy your Hugo build output to Cloudflare Pages, GitHub Pages, Vercel, or a plain S3 bucket, the form returns 404 on submit because the rewrite logic lives in Netlify's build, not in your HTML.
It also has limits that bite quickly. Netlify Forms gives you 100 submissions per month on the free tier. The next step up is $19/month for 1,000 submissions. splitforms gives you 1,000 submissions/month free and $5/month for 5,000. The cost gap is real, and the host coupling is worse — your form handler should outlive whichever CDN you happen to use this year.
The fix is to stop relying on host-specific build magic. A regular HTML form with action="https://splitforms.com/api/submit" works on every static host, doesn't need a build plugin, and survives migrations. The full splitforms vs Netlify Forms breakdown covers feature-by-feature tradeoffs, but the one-line summary is that you keep the same Hugo source files no matter where you deploy, and you only pay for actual submissions instead of a host bundle.
Same logic applies if you were considering Formspree, Web3Forms, Basin, or Getform. They all work fine, but splitforms gives you more headroom on the free tier (1,000/month vs Formspree's 50/month and Web3Forms' 250/month), free webhooks instead of paywalled webhooks, and an AI spam classifier that catches the polite-sounding bot copy that keyword filters miss. Side-by-side coverage in splitforms vs Formspree and splitforms vs Web3Forms.
Step 1: Get a splitforms access key (1 minute)
- Open splitforms.com/login in a new tab.
- Enter your email. A 6-digit code lands in your inbox in under 10 seconds.
- Paste the code. You're in. The dashboard shows your first access key already generated.
- Copy it. It looks like
sf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
No credit card, no plan selection. The free tier covers 1,000 submissions per month, includes free webhooks, AI spam classification, and the full dashboard. Most Hugo blogs and personal sites never leave that tier. If your site eventually outgrows it, $5/month gets you 5,000 submissions and $59 covers four years of Pro. The pricing FAQ has the long version.
Before you leave the dashboard, set Allowed Domains to your real Hugo site domain (and localhost:1313 if you want to test locally with hugo server). This makes the access key worthless to anyone who copies it from your page source.
Step 2: Build the Hugo partial
Create layouts/partials/contact-form.html in your Hugo project root. If the layouts/partials/ directory doesn't exist, create it. This file is the entire form integration — no other Hugo files need to change.
{{/* layouts/partials/contact-form.html */}}
<form
action="https://splitforms.com/api/submit"
method="POST"
class="contact-form"
>
<input type="hidden" name="access_key" value="{{ site.Params.splitformsAccessKey }}" />
<input type="hidden" name="subject" value="New message from {{ site.Title }}" />
<input type="hidden" name="redirect" value="{{ "/thanks/" | absURL }}" />
<label>
Name
<input type="text" name="name" required autocomplete="name" />
</label>
<label>
Email
<input type="email" name="email" required autocomplete="email" />
</label>
<label>
Message
<textarea name="message" rows="5" required></textarea>
</label>
{{/* Honeypot — humans never fill this, bots usually do */}}
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
<button type="submit">Send message</button>
</form>Then add the access key to your site's hugo.toml (or config.toml on older sites):
# hugo.toml
[params]
splitformsAccessKey = "sf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Keeping the key in site.Params rather than hard-coded in the partial means you can rotate it from one file. If you want to keep the key out of your committed config entirely, set it via HUGO_PARAMS_SPLITFORMSACCESSKEY as an environment variable at build time — Hugo merges environment params into site.Params automatically.
Step 3: Wrap it in a shortcode for markdown
If your contact page is a markdown file (content/contact.md), you can't call a partial from markdown directly — you need a shortcode. Create layouts/shortcodes/contact-form.html:
{{/* layouts/shortcodes/contact-form.html */}}
{{ partial "contact-form.html" . }}That's the whole shortcode. It just re-uses the partial so you have one source of truth. Now write your contact page:
---
title: "Contact"
date: 2026-05-10
---
Get in touch. I read everything.
{{< contact-form >}}
Hugo renders {{< contact-form >}} by inlining the partial's HTML at that exact spot. If you have multiple contact pages (one per locale, for example), each one gets the same form with the same access key and the same email destination. The splitforms docs cover the full request contract if you want to add custom fields, file uploads, or multiple recipients.
Using it in templates instead
If you'd rather drop the form into a template (like layouts/_default/single.html or your site footer), skip the shortcode entirely and call the partial directly: {{ partial "contact-form.html" . }}. Same HTML output, different invocation context.
Step 4: Style with Hugo Pipes
Hugo Pipes is the asset pipeline that handles CSS, JS, and image processing without a separate Webpack build. Put your form CSS in assets/css/contact-form.css:
/* assets/css/contact-form.css */
.contact-form { display: grid; gap: 14px; max-width: 520px; }
.contact-form label { display: grid; gap: 4px; font-size: 14px; font-weight: 600; }
.contact-form input,
.contact-form textarea {
font: inherit;
padding: 10px 12px;
border: 1px solid #d4d4d8;
border-radius: 8px;
background: #fff;
}
.contact-form input:focus,
.contact-form textarea:focus { outline: 2px solid #2563eb; outline-offset: 1px; }
.contact-form button {
height: 44px;
padding: 0 22px;
border: 0;
border-radius: 999px;
background: #0c0d0f;
color: #fafaf7;
font-weight: 700;
cursor: pointer;
}Reference it from your base template (layouts/_default/baseof.html) using resources.Get and pipe it through minify and fingerprint for cache-busting:
{{ $css := resources.Get "css/contact-form.css" | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" />Hugo fingerprints the filename (contact-form.abc123.css) so browsers don't serve stale CSS after you edit it. This is the same pipeline that ships your theme's CSS — no new tooling.
Step 5: Validation and spam protection
You already have HTML5 validation via required and type="email". That covers the happy path. For spam, the partial includes a honeypot — a checkbox named botcheck hidden with inline CSS. Real users never see it, so it stays unchecked. Most spam bots blindly fill every field they see, so they check it. splitforms drops any submission where botcheck has a truthy value.
The honeypot alone catches roughly 90% of automated spam in measured field data. The remaining 10% — submissions from bots smart enough to skip hidden fields — get caught by splitforms' AI content classifier, which scores each message for spam-like patterns before it hits your inbox. The honeypot vs reCAPTCHA comparison walks through the data; the short version is the combination beats reCAPTCHA v3 on both false-positives and false-negatives, with zero UX friction.
Server-side validation is automatic. splitforms rejects submissions with malformed email addresses, missing required fields based on your dashboard config, and payloads above the size cap. That means the worst case if a user bypasses your HTML5 validation in DevTools is a clean 400 response, not a junk email in your inbox. For richer validation rules — phone format, country allow-lists, custom regex — define them in the dashboard rather than the HTML so they apply consistently across every page that embeds the shortcode.
If you want belt-and-suspenders, add Cloudflare Turnstile as a third layer. Hugo can render the Turnstile widget script directly in the partial. But for a personal site or small business contact form, honeypot + AI classification is enough — every layer you add is more JS and more chance of locking out a real user with an old browser.
Step 6: Build and deploy on any static host
Run your normal build command:
hugo --gc --minifyThat outputs to public/. Deploy public/ to whichever static host you prefer. The form action URL is hard-coded into the HTML, so the form works identically on every one:
- Cloudflare Pages. Connect the repo, set build command to
hugo --gc --minify, output directory topublic. Free tier covers unlimited bandwidth. - GitHub Pages. Use the official Hugo workflow at
.github/workflows/hugo.yml. Set the repo Pages source to GitHub Actions. Free for public repos. - Vercel. Detects Hugo automatically. Set the framework preset to Hugo and the output directory to
public. - Netlify. Yes, you can still host on Netlify — just don't use Netlify Forms. The splitforms-backed form works the same way it does everywhere else.
- S3 + CloudFront, your own VPS, Caddy on a Raspberry Pi. Anywhere that serves static HTML works.
Set baseURL in hugo.toml to your production domain (e.g. baseURL = "https://yoursite.com/") so absolute URLs in redirect hidden inputs resolve correctly. Browse the rest of the splitforms blog for platform-specific deploy guides.
Step 7: Test the end-to-end path
- Run
hugo serverlocally. Openhttp://localhost:1313/contact/. - Fill in the form with a real email address. Submit.
- You should land on
/thanks/(or wherever yourredirectpoints). If you didn't set up a thanks page, splitforms shows a default success JSON page. - Check the inbox associated with your splitforms account. The notification email arrives in under 10 seconds in typical cases.
- Open your splitforms submissions dashboard. The test submission shows up with timestamp, IP, and full payload.
If the email never arrives, check your spam folder first, then the dashboard. If the dashboard has the submission but the email didn't land, it's a deliverability issue — covered in the contact form not working guide. If the dashboard is empty too, the submission never reached splitforms — see the troubleshooting section below.
Troubleshooting common Hugo gotchas
- CORS error in the browser console. You shouldn't see one with a standard form POST — CORS only triggers for fetch/XHR. If you're submitting via JavaScript instead of a native form post, set the request to
credentials: "omit"and don't add custom headers that trigger a preflight. The splitforms endpoint accepts standard CORS-friendly requests from any origin allowed by your access key's Allowed Domains list. - 401 unauthorized. The access key is wrong, has leading whitespace, or your current domain isn't in Allowed Domains. Re-copy the key from the dashboard and add your real domain (and
localhost:1313for local dev). - Form HTML cached on Cloudflare or your CDN. If you redeployed but the old form is still showing, purge the CDN cache. Cloudflare Pages caches HTML aggressively; use the dashboard's Purge Everything after a deploy that changes the form.
- baseURL mismatch. Hugo's
absURLand"/thanks/" | absURLresolve against your configuredbaseURL. IfbaseURLis still set tohttp://localhost:1313/in production, the redirect input will point at localhost and break. Always set the productionbaseURLbefore building for deploy. - Honeypot catching real users. A small number of password managers and accessibility tools auto-fill checkboxes. Adding
autocomplete="off"andtabindex="-1"(which the partial already does) prevents this in 99% of cases. - Submissions arrive but missing fields. Check that every
<input>has anameattribute. Hugo doesn't strip these, but theme HTML sometimes usesidwithoutname, which the browser silently omits from form data.
Next steps
- Need the form on a different stack? See Next.js, Astro, Vue, or Svelte integrations.
- Already using Formspree? The 5-minute Formspree migration guide applies to Hugo too — only the action URL changes.
- Want a ready-to-paste template? Grab the free HTML contact form pre-wired to splitforms.
- Hooking up Slack, Discord, or Zapier on submit? Webhooks are free on every plan — see the API reference.
- Ready to ship? Sign up at splitforms.com/login and your access key is waiting.
FAQ
Why can't I just use Hugo's built-in form support?
Hugo doesn't have built-in form handling because it's a static site generator — it compiles markdown into HTML files. There's no server running after the build, so there's no place to receive POST requests. Any contact form on a Hugo site has to point its action attribute at an external endpoint (splitforms, Formspree, Netlify Forms, your own Lambda). Hugo's job ends at HTML; submission handling is always external.
Do I have to host on Netlify to get forms working?
No, and you shouldn't if you want portability. Netlify Forms work by Netlify's build system detecting a special data-netlify attribute and rewriting your HTML. That ties your form handling to one host. If you move to Cloudflare Pages, GitHub Pages, or Vercel later, the form silently breaks. A splitforms-backed form posts to splitforms.com/api/submit and works identically regardless of where you host the static output.
Should I use a partial or a shortcode for the form?
Use a shortcode if you want to embed the form inside markdown content (a contact page written in content/contact.md, for example). Use a partial if the form lives in a template layout like the site footer or a sidebar. Shortcodes are called from markdown with {{< contact-form >}}; partials are called from HTML templates with {{ partial "contact-form.html" . }}. Both can wrap the same underlying HTML.
How do I stop spam without adding reCAPTCHA?
Add a honeypot field — a hidden input named botcheck that real humans never fill in but bots usually do. splitforms automatically rejects submissions where botcheck has any value. This blocks roughly 90% of automated spam without the friction of a CAPTCHA. For the remaining 10%, splitforms runs AI-based content classification on every submission. The combination is more accurate than reCAPTCHA v3 and invisible to your users.
My access key is showing in the page source — is that a security problem?
No. Access keys are designed to be public, the same way Stripe publishable keys or Google Maps API keys are. They identify which splitforms account receives the submission. To prevent abuse you set Allowed Domains in the dashboard so the key only accepts submissions originating from your real domain. Anyone copying the key from your source code can't use it from their own site.
Will the form work after I run hugo --gc --minify?
Yes. Hugo's minifier preserves HTML attributes, including the form action URL and the access_key hidden input. The garbage-collection flag only removes unused cache files. If you're seeing the form break after a minified build, the most likely cause is Hugo Pipes processing the CSS or JS — check that any form-related fingerprinted asset is still being referenced from the rendered HTML.
Can I redirect to a thank-you page after submit?
Yes. Add a hidden input named redirect with the absolute URL of the thank-you page: <input type="hidden" name="redirect" value="https://yoursite.com/thanks/" />. After splitforms accepts the submission, the user is 302-redirected to that URL. Build the /thanks/ page as a normal Hugo content page (content/thanks.md) and Hugo will render it as a static HTML file like any other.
Do I need a build plugin or a Hugo module for this?
No. The entire integration is a regular Hugo partial or shortcode plus an HTML form pointing at splitforms.com/api/submit. There's no Go code, no module dependency, and nothing to install. That's the point of using a static-site-friendly form backend — your Hugo project stays exactly as it was, and a single HTML file in layouts/partials/ or layouts/shortcodes/ wires everything up.