Hugo Contact Form - Complete Setup Guide (No Backend Needed)
Add a working contact form to your Hugo site in under 10 minutes. No server, no backend code - just HTML and StaticForm. Includes reusable shortcodes and spam protection that blocks 84% of junk.
Hugo Contact Form - Complete Setup Guide (No Backend Needed)
Hugo is fast. Like, embarrassingly fast. It builds thousands of pages in milliseconds, ships zero JavaScript by default, and makes you wonder why you ever used anything else for content sites.
Then you need a contact form.
Hugo generates static HTML. There’s no server processing your requests, no database storing submissions, no backend logic running when someone clicks “Send.” Your beautifully fast site just… can’t handle a form. You’re stuck choosing between bolting on a third-party JavaScript widget, setting up a separate backend service, or telling visitors to email you directly like it’s 2005.
We built StaticForm because we got tired of solving this exact problem. A form backend that works with static sites out of the box - no server needed, no JavaScript frameworks required. Just point your HTML form at a URL and you’re done.
This guide covers everything you need to add a hugo contact form to your site: basic setup, reusable shortcodes, spam protection, custom thank-you pages, and production deployment tips.
The Hugo Form Problem
Let’s be specific about what makes forms hard in Hugo.
Hugo is a static site generator. When you run hugo build, it produces a folder full of HTML, CSS, and maybe some JavaScript. You upload that folder to a web server (or a CDN like Netlify or Cloudflare Pages), and that’s your site. There’s no application server running. No Node.js, no PHP, no Python. Just files being served.
That’s great for performance and security. But forms need a destination. When someone fills out your contact form and clicks submit, that data has to go somewhere. With a traditional server-rendered site, the form posts back to the same server, which processes it. With Hugo, there is no server to post back to.
Your options without a form backend:
- Mailto links - Not a real form. Opens the user’s email client, which is clunky and means you lose all structure.
- Netlify Forms - Works if you’re on Netlify, but ties you to their platform. Their spam detection is basic, and you’re locked in.
- Google Forms embed - Looks terrible and breaks your site’s design. Plus, good luck styling it.
- Build a separate API - Now you’re maintaining a backend just for a contact form. Congratulations, you’ve defeated the purpose of using Hugo.
None of these are great. What you actually want is a form that looks like part of your site, submits to a reliable endpoint, filters spam automatically, and notifies you when a real person reaches out.
That’s what a form backend does.
What is StaticForm?
StaticForm is a form backend service. You create a form endpoint in our dashboard, point your HTML form’s action attribute at it, and we handle everything that happens after the user clicks submit:
- Receive and store the submission in EU-based servers (GDPR-compliant)
- Filter spam automatically using multiple detection layers
- Notify you via email, Slack, Discord, or custom webhooks
- Log every step so you can debug delivery issues
It works with any static site generator - Hugo, Jekyll, Eleventy, Astro, plain HTML - because the integration is just a standard HTML form. No build plugins, no JavaScript SDK, no framework lock-in.
Here’s the thing about running forms on the internet in 2026: 84% of all submissions across our platform are spam. That’s real data from thousands of forms. For every 10 submissions your contact form receives, roughly 8 are junk - SEO pitches, phishing attempts, bot-generated garbage. If you’re not filtering spam, you’re drowning in it.
StaticForm’s multi-layer spam detection catches the vast majority of this automatically. And unlike most competitors, spam doesn’t count against your submission quota. You only pay for real submissions from real people.
Basic HTML Form Setup with Hugo
Let’s start with the simplest possible approach. No shortcodes, no partials - just a plain HTML form in a Hugo template.
Step 1: Create Your Form Endpoint
- Sign up at app.staticform.app (no credit card required)
- Click “Create Form”
- Name it something like “Hugo Contact Form”
- Copy your endpoint URL:
https://api.staticform.app/api/v1/forms/019be6a7-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Every form gets 10 free test credits so you can verify everything works before committing.
Step 2: Create a Contact Page
In Hugo, you can create a dedicated contact page layout. First, create the content file:
<!-- content/contact.md -->
---
title: "Contact"
layout: "contact"
---
Have a question or want to work together? Fill out the form below and we'll get back to you within 24 hours.
Step 3: Create the Layout Template
Now create the template that renders the form:
<!-- layouts/page/contact.html -->
{{ define "main" }}
<div class="contact-page">
<h1>{{ .Title }}</h1>
{{ .Content }}
<form
action="https://api.staticform.app/api/v1/forms/YOUR_FORM_ID"
method="POST"
class="contact-form"
>
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="subject">Subject</label>
<input type="text" id="subject" name="subject" required />
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<!-- Honeypot field - invisible to real users, catches basic bots -->
<input type="text" name="_gotcha" style="display:none" />
<button type="submit" class="btn btn-primary">Send Message</button>
</form>
</div>
{{ end }}
That’s a working contact form. When someone submits it, the data goes to StaticForm, gets checked for spam, and you get notified through whatever channels you’ve configured in the dashboard. The whole thing took maybe 5 minutes.
The _gotcha field is a honeypot. Real users never see it (it’s hidden with CSS), but spam bots tend to fill in every field they find. StaticForm uses this as one of several spam signals.
Step 3.5: Add Some CSS
The form works without styling, but let’s make it look decent:
/* assets/css/contact.css or in your main stylesheet */
.contact-form {
max-width: 600px;
margin: 2rem 0;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.btn-primary {
background-color: #3b82f6;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
}
.btn-primary:hover {
background-color: #2563eb;
}
Hugo Shortcode for Reusable Forms
If you need contact forms on multiple pages - or you want content editors to drop a form into any markdown file - a Hugo shortcode is the way to go.
Create the Shortcode
<!-- layouts/shortcodes/contact-form.html -->
{{ $formId := .Get "formId" | default "YOUR_DEFAULT_FORM_ID" }}
{{ $successUrl := .Get "successUrl" | default (printf "%s/thank-you/" .Page.Site.BaseURL) }}
{{ $buttonText := .Get "buttonText" | default "Send Message" }}
{{ $showSubject := .Get "showSubject" | default "true" }}
<form
action="https://api.staticform.app/api/v1/forms/{{ $formId }}"
method="POST"
class="contact-form"
>
<!-- Redirect URL after successful submission -->
<input type="hidden" name="_redirect" value="{{ $successUrl }}" />
<div class="form-group">
<label for="cf-name">Name</label>
<input
type="text"
id="cf-name"
name="name"
placeholder="Your name"
required
/>
</div>
<div class="form-group">
<label for="cf-email">Email</label>
<input
type="email"
id="cf-email"
name="email"
placeholder="you@example.com"
required
/>
</div>
{{ if eq $showSubject "true" }}
<div class="form-group">
<label for="cf-subject">Subject</label>
<input
type="text"
id="cf-subject"
name="subject"
placeholder="What's this about?"
required
/>
</div>
{{ end }}
<div class="form-group">
<label for="cf-message">Message</label>
<textarea
id="cf-message"
name="message"
rows="6"
placeholder="Your message..."
required
></textarea>
</div>
<!-- Honeypot - hidden from real users -->
<div style="position:absolute;left:-9999px" aria-hidden="true">
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
</div>
<button type="submit" class="btn btn-primary">
{{ $buttonText }}
</button>
</form>
Using the Shortcode
Now any content editor can drop a form into a markdown file:
---
title: "Work With Us"
---
Interested in collaborating? Fill out the form and we'll be in touch.
{{</* contact-form formId="019be6a7-xxxx-xxxx-xxxx-xxxxxxxxxxxx" */>}}
You can customize the behavior with parameters:
<!-- Custom button text, no subject field -->
{{</* contact-form
formId="019be6a7-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
buttonText="Get a Quote"
showSubject="false"
*/>}}
<!-- Custom success redirect -->
{{</* contact-form
formId="019be6a7-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
successUrl="https://yoursite.com/thanks/"
*/>}}
This is one of Hugo’s best features for form handling. The shortcode keeps all the form HTML in one place, so if you need to update the form structure later, you change it once and every page that uses the shortcode gets updated automatically.
Multiple Forms with Different Endpoints
Need a contact form and a separate newsletter signup? Create different StaticForm endpoints and use different form IDs:
## Contact Us
{{</* contact-form formId="YOUR_CONTACT_FORM_ID" */>}}
## Newsletter
{{</* contact-form
formId="YOUR_NEWSLETTER_FORM_ID"
buttonText="Subscribe"
showSubject="false"
*/>}}
Each form has its own endpoint, its own spam filtering, and its own notification settings. You configure those separately in the StaticForm dashboard.
Spam Protection - Why It Matters for Hugo Sites
Here’s something most Hugo form tutorials skip: spam is a massive problem, and it gets worse the longer your site is online.
Bots crawl the web looking for forms. They find yours, and they start submitting garbage - SEO link spam, phishing attempts, crypto scams, you name it. Our data shows 84% of all form submissions across StaticForm are spam. That’s the current state of the internet.
For a Hugo site with no backend, you can’t run server-side spam checks yourself. You’re entirely dependent on whatever service handles your form submissions. So how that service handles spam really matters.
StaticForm’s Multi-Layer Approach
We don’t rely on a single method. Spam detection works best when you stack multiple signals:
-
Honeypot fields - The
_gotchafield catches dumb bots that fill in every visible input. It’s old-school but still effective for the bottom tier of spam. -
IP reputation analysis - We check the submitter’s IP against known spam networks. Repeat offenders and known bot IPs get blocked before the submission is even processed.
-
Content analysis - The actual text of the submission gets analyzed for spam patterns. Link stuffing, known spam phrases, suspicious formatting, common phishing templates - all scored.
-
Language detection - Getting submissions in a language that doesn’t match your site’s audience? That’s a signal. Not an automatic block, but it factors into the overall spam score.
-
Behavioral analysis - How fast was the form completed? Did the submission come from a headless browser? Was JavaScript enabled? These behavioral signals help separate real humans from sophisticated bots.
Each submission gets a spam probability score. High-confidence spam gets filtered automatically. Borderline cases get flagged for your review in the dashboard.
Why This Matters for Your Wallet
Most form backend services count every submission against your quota - spam included. If you’re on a plan with 500 submissions per month and 84% are spam, you’re burning through 420 submissions on junk. That leaves you 80 real submissions before you hit your limit.
On StaticForm, spam submissions don’t count against your quota. You only pay for legitimate submissions from real people. That’s not a minor detail - it fundamentally changes the economics.
No reCAPTCHA Required
You might be thinking: “Why not just add reCAPTCHA?” You can, and it does reduce spam. But it also adds friction for real users. Nobody enjoys clicking through “select all traffic lights” puzzles. reCAPTCHA also loads external JavaScript from Google, which hurts your page load time and raises privacy concerns - especially for EU visitors.
StaticForm’s spam filtering runs entirely server-side, after submission. Zero impact on user experience, zero external scripts, zero privacy tradeoffs. Your visitors never know it’s there.
Custom Thank-You Pages with Hugo
By default, when someone submits a form via a standard HTML POST, they get redirected. You want to control where they end up.
Create a Thank-You Page
First, create the content:
<!-- content/thank-you.md -->
---
title: "Thanks for Reaching Out"
layout: "thank-you"
noindex: true
---
Then create the layout:
<!-- layouts/page/thank-you.html -->
{{ define "main" }}
<div class="thank-you-page">
<div class="thank-you-content">
<h1>{{ .Title }}</h1>
<p>Your message has been received. We typically respond within 24 hours.</p>
<p>
In the meantime, you might want to check out our
<a href="{{ .Site.BaseURL }}/blog/">latest blog posts</a>
or head back to the <a href="{{ .Site.BaseURL }}">homepage</a>.
</p>
</div>
</div>
{{ end }}
Configure the Redirect
In your form, add a hidden field that tells StaticForm where to send the user after a successful submission:
<form
action="https://api.staticform.app/api/v1/forms/YOUR_FORM_ID"
method="POST"
>
<!-- Redirect to your custom thank-you page -->
<input type="hidden" name="_redirect" value="{{ .Site.BaseURL }}/thank-you/" />
<!-- rest of your form fields... -->
</form>
If you’re using the shortcode we built earlier, the redirect is already built in via the successUrl parameter:
{{</* contact-form
formId="YOUR_FORM_ID"
successUrl="https://yoursite.com/thank-you/"
*/>}}
SEO Note
Add noindex: true to your thank-you page frontmatter and make sure your Hugo theme’s <head> partial respects it. You don’t want Google indexing your thank-you page - it’s not useful content for search visitors:
<!-- layouts/partials/head.html -->
{{ if .Params.noindex }}
<meta name="robots" content="noindex, nofollow" />
{{ end }}
JavaScript-Enhanced Form (Optional)
The plain HTML form works perfectly. But if you want inline success/error messages without a page redirect, you can add a small JavaScript enhancement.
Create a partial that your contact layout includes:
<!-- layouts/partials/contact-form-enhanced.html -->
<form
id="contact-form"
action="https://api.staticform.app/api/v1/forms/YOUR_FORM_ID"
method="POST"
class="contact-form"
>
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required></textarea>
</div>
<div style="position:absolute;left:-9999px" aria-hidden="true">
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
</div>
<div id="form-status" class="form-status" style="display:none"></div>
<button type="submit" id="submit-btn" class="btn btn-primary">
Send Message
</button>
</form>
<script>
document.getElementById('contact-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.target;
const btn = document.getElementById('submit-btn');
const status = document.getElementById('form-status');
const formData = new FormData(form);
btn.disabled = true;
btn.textContent = 'Sending...';
status.style.display = 'none';
try {
const response = await fetch(form.action, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const problem = await response.json();
throw new Error(problem.detail || 'Something went wrong');
}
status.className = 'form-status success';
status.textContent = 'Message sent! We will get back to you soon.';
status.style.display = 'block';
form.reset();
} catch (err) {
status.className = 'form-status error';
status.textContent = err.message || 'Failed to send. Please try again.';
status.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Send Message';
}
});
</script>
Add some status styles:
.form-status {
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-weight: 500;
}
.form-status.success {
background-color: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.form-status.error {
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
This script intercepts the form submission, sends it via fetch() instead of a full page navigation, and shows an inline status message. If JavaScript fails to load (or is disabled), the form falls back to the standard HTML submission with redirect. Progressive enhancement at its finest.
Using Hugo Data Files for Form Configuration
Hugo’s data files let you centralize configuration. If you’re managing multiple forms across a site, this keeps things tidy.
Create a data file:
# data/forms.yaml
contact:
endpoint: "https://api.staticform.app/api/v1/forms/YOUR_CONTACT_FORM_ID"
success_url: "/thank-you/"
button_text: "Send Message"
fields:
- name: "name"
label: "Name"
type: "text"
required: true
- name: "email"
label: "Email"
type: "email"
required: true
- name: "message"
label: "Message"
type: "textarea"
required: true
rows: 6
quote:
endpoint: "https://api.staticform.app/api/v1/forms/YOUR_QUOTE_FORM_ID"
success_url: "/quote-received/"
button_text: "Request Quote"
fields:
- name: "name"
label: "Name"
type: "text"
required: true
- name: "email"
label: "Email"
type: "email"
required: true
- name: "company"
label: "Company"
type: "text"
required: false
- name: "budget"
label: "Budget Range"
type: "select"
required: true
options:
- "Under $5,000"
- "$5,000 - $15,000"
- "$15,000 - $50,000"
- "$50,000+"
- name: "details"
label: "Project Details"
type: "textarea"
required: true
rows: 8
Then build a dynamic shortcode that reads from this data:
<!-- layouts/shortcodes/dynamic-form.html -->
{{ $formName := .Get "form" | default "contact" }}
{{ $formConfig := index .Site.Data.forms $formName }}
{{ if $formConfig }}
<form
action="{{ $formConfig.endpoint }}"
method="POST"
class="contact-form"
>
<input type="hidden" name="_redirect" value="{{ .Page.Site.BaseURL }}{{ $formConfig.success_url }}" />
{{ range $formConfig.fields }}
<div class="form-group">
<label for="df-{{ .name }}">{{ .label }}{{ if .required }} *{{ end }}</label>
{{ if eq .type "textarea" }}
<textarea
id="df-{{ .name }}"
name="{{ .name }}"
rows="{{ .rows | default 4 }}"
{{ if .required }}required{{ end }}
></textarea>
{{ else if eq .type "select" }}
<select
id="df-{{ .name }}"
name="{{ .name }}"
{{ if .required }}required{{ end }}
>
<option value="">Select...</option>
{{ range .options }}
<option value="{{ . }}">{{ . }}</option>
{{ end }}
</select>
{{ else }}
<input
type="{{ .type }}"
id="df-{{ .name }}"
name="{{ .name }}"
{{ if .required }}required{{ end }}
/>
{{ end }}
</div>
{{ end }}
<div style="position:absolute;left:-9999px" aria-hidden="true">
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
</div>
<button type="submit" class="btn btn-primary">
{{ $formConfig.button_text | default "Submit" }}
</button>
</form>
{{ else }}
<p><em>Form "{{ $formName }}" not found in data/forms.yaml</em></p>
{{ end }}
Use it in your content:
## Get in Touch
{{</* dynamic-form form="contact" */>}}
## Request a Quote
{{</* dynamic-form form="quote" */>}}
This approach scales well. When you need a new form, add it to data/forms.yaml and use the shortcode. No new templates to create.
Production Deployment Tips
Hugo sites deploy to all sorts of places. Here’s what to know for the most common platforms.
Netlify
Netlify works out of the box with StaticForm. Your hugo build output gets deployed to their CDN, and your forms POST directly to StaticForm’s API. No configuration needed beyond what we’ve already covered.
One thing to watch: Netlify tries to detect forms in your HTML and hook them into their own form handling. If you see Netlify intercepting your submissions, add data-netlify="false" to your <form> tag or make sure you don’t have netlify attributes on it.
<form
action="https://api.staticform.app/api/v1/forms/YOUR_FORM_ID"
method="POST"
data-netlify="false"
>
<!-- fields -->
</form>
Cloudflare Pages
Works perfectly. Deploy your Hugo site to Cloudflare Pages and forms submit directly to StaticForm. No extra config required.
If you’re using Cloudflare’s bot protection features, make sure they’re not blocking the form redirect back from StaticForm. This is rarely an issue, but worth checking if submissions seem to work but users don’t land on your thank-you page.
Vercel
Same story. Hugo static export on Vercel, forms POST to StaticForm. Zero friction.
GitHub Pages
Works great. The only caveat: GitHub Pages doesn’t support custom server-side redirects, so make sure your thank-you page exists as an actual page in your Hugo site (not a server-side redirect). The approach we covered above - creating content/thank-you.md - handles this correctly.
General Tips
- Use environment variables for form IDs - If you’re running staging and production environments, use Hugo’s environment features to swap form endpoints:
<!-- layouts/partials/form-endpoint.html -->
{{ if eq hugo.Environment "production" }}
{{ $endpoint := "https://api.staticform.app/api/v1/forms/PROD_FORM_ID" }}
{{ else }}
{{ $endpoint := "https://api.staticform.app/api/v1/forms/TEST_FORM_ID" }}
{{ end }}
Or set it in your Hugo config:
# hugo.toml (production)
[params]
contactFormId = "YOUR_PRODUCTION_FORM_ID"
# hugo.staging.toml
[params]
contactFormId = "YOUR_STAGING_FORM_ID"
Then reference it in templates:
<form action="https://api.staticform.app/api/v1/forms/{{ .Site.Params.contactFormId }}" method="POST">
-
Test with free credits first - Every StaticForm form gets 10 free test credits. Use them to verify your setup works before going live. Check that submissions arrive, notifications fire, and redirects work correctly.
-
Set up notifications before launch - Configure your email, Slack, or Discord notifications in the StaticForm dashboard before your site goes live. You don’t want to miss your first real lead because you forgot to set up email forwarding.
-
Monitor execution logs - After launch, check the StaticForm dashboard periodically. Execution logs show you if notifications are failing (expired webhook, bounced email, etc.). Catch problems before your visitors notice.
StaticForm vs Other Hugo Form Options
Since you’re evaluating options, here’s a quick comparison:
| Feature | StaticForm | Formspree | Netlify Forms | Basin |
|---|---|---|---|---|
| Works on any host | Yes | Yes | Netlify only | Yes |
| Free tier | 10 credits/form | 50/month | 100/month | 100/month |
| Paid from | €4/month | $10/month | $19/month | $19/month |
| Spam counts against quota | No | Yes | Yes | Yes |
| Data storage location | EU | US | US | US |
| Execution logs | Yes | No | No | No |
| No JavaScript required | Yes | Yes | Yes (with attributes) | Yes |
The standout differences: StaticForm stores data in Europe (important for GDPR compliance), doesn’t charge you for spam, and works on any hosting platform - not just Netlify.
Netlify Forms is convenient if you’re already on Netlify, but it locks you in. Move to Cloudflare Pages or Vercel and you need to rip out your form handling entirely. With StaticForm, your form works the same regardless of where you host.
Getting Started
Here’s the quickest path from zero to a working hugo contact form:
- Create a free account at app.staticform.app
- Create a form and copy the endpoint URL
- Add the form HTML to your Hugo template or use the shortcode approach
- Replace
YOUR_FORM_IDwith your actual endpoint - Submit a test - use your 10 free credits to verify everything works
- Set up notifications - email, Slack, Discord, or webhooks
Five minutes of setup. No backend to maintain, no spam to manually filter, no server to keep running.
Hugo handles the fast, static front end. StaticForm handles the form backend. Each tool does what it’s good at.
If your project outgrows the free credits, plans start at €4/month for 500 submissions. And since spam doesn’t count against your quota, those are 500 real submissions.
Try StaticForm free - 10 free credits per form, no credit card required.