A contact form for your Hugo site in four minutes (no Netlify required)

April 4, 2026 · 5 min read

Secure access concept — a Hugo site posting to a hosted form endpoint

Hugo people are a very specific breed. I mean that with affection. We like fast builds. We like single binaries. We don't install 400 npm packages to render a blog. And we look slightly suspicious of anything that asks us to opt into a specific host.

Most Hugo form tutorials on the internet assume you're on Netlify and happy to use netlify form attributes. That's fine if you're on Netlify. If you're on Cloudflare Pages, or your own VPS, or GitHub Pages, or a bucket behind a CDN, you want something portable.

Here's a portable version. Four minutes, host-agnostic, works on any static deploy.

The approach, in one sentence

Write a plain HTML form in your Hugo template, point its action at a hosted form backend, and let that backend handle delivery. Your Hugo site stays fully static. Your form just works.

Step 1 — Grab an endpoint

Sign up for any hosted form backend. FormTo will give you a URL like:

https://api.formto.dev/f/your-form-slug

If you picked a different tool, the URL shape will be similar. Copy it.

Step 2 — Create the contact page

Hugo content files live in content/. Create content/contact.md:

---
title: "Contact"
layout: "contact"
url: "/contact/"
---

The layout line tells Hugo to use a specific template for this page, which is where the form will live. This keeps your Markdown clean — no raw HTML in your content files, no confused commit diffs later.

Step 3 — Create the layout

Create layouts/_default/contact.html (or layouts/page/contact.html depending on your theme structure):

{{ define "main" }}
<main class="contact">
  <h1>{{ .Title }}</h1>
  <p>Send us a note. We reply within a day or two.</p>

  <form method="POST" action="{{ .Site.Params.contactEndpoint }}">
    <label>
      Your name
      <input type="text" name="name" required>
    </label>

    <label>
      Email
      <input type="email" name="email" required>
    </label>

    <label>
      Message
      <textarea name="message" rows="5" required></textarea>
    </label>

    <!-- honeypot -->
    <input type="text" name="website" tabindex="-1" autocomplete="off"
           style="position:absolute;left:-9999px" aria-hidden="true">

    <input type="hidden" name="_redirect" value="{{ .Site.BaseURL }}thanks/">

    <button type="submit">Send</button>
  </form>
</main>
{{ end }}

Notice the {{ .Site.Params.contactEndpoint }} — that's a Hugo parameter we're about to set. Putting the URL in config.toml (or hugo.toml) instead of hard-coding it in the template means you can swap endpoints later without editing templates, and you can have a different endpoint in staging.

Step 4 — Wire up the site config

Add this to your hugo.toml:

[params]
contactEndpoint = "https://api.formto.dev/f/your-form-slug"

Or in config.yaml:

params:
  contactEndpoint: "https://api.formto.dev/f/your-form-slug"

Build with hugo. Open /contact/ in the browser. You should see your form.

The thank-you page

Make content/thanks.md:

---
title: "Thanks"
url: "/thanks/"
---

Got it. We'll reply within a day or two.

That's it. The _redirect hidden input in the form will bounce submitters here after a successful post.

Why this is better than the Netlify approach

Netlify forms are genuinely convenient if you're on Netlify. But:

  • You're locked into the host for the form layer.
  • Free tier is 100 submissions a month, after which it gets expensive.
  • The dashboard is… existentially minimal.
  • Spam filtering is weak out of the box. You'll end up adding reCAPTCHA, which adds weight to your otherwise-fast Hugo site.
  • Moving off Netlify later means rewriting every form.

With a hosted endpoint, the form is plain HTML. You can deploy the same Hugo site to Cloudflare Pages, to an S3 bucket, to your aunt's Raspberry Pi. The form works identically everywhere.

Testing it without deploying

Here's a Hugo-specific gotcha. If you test the form from localhost:1313, most form backends will happily accept the submission and warn you about the origin. That's fine. What's not fine is assuming localhost tests confirm production behavior.

Before you ship:

  1. Deploy to a real domain.
  2. Open the form on your phone, on mobile data, not on your home Wi-Fi.
  3. Submit it.
  4. Check the backend dashboard for the submission.
  5. Check your email/Slack notification actually arrived.
  6. Fill in the honeypot field (document.querySelector('[name=website]').value = 'bot' in the console) and confirm that submission does not reach your notifications.

Five minutes of testing. Saves a week of wondering why your contact page is a ghost town.

The Hugo-specific bit I'm fond of

You can define your form fields in a data file and loop over them in the template. That way a non-developer can edit the form by touching one YAML file, without learning Hugo's templating syntax. Something like:

# data/contact.yaml
fields:
  - { name: "name", label: "Your name", type: "text", required: true }
  - { name: "email", label: "Email", type: "email", required: true }
  - { name: "message", label: "Message", type: "textarea", required: true }

Then in the template:

{{ range .Site.Data.contact.fields }}
  <label>
    {{ .label }}
    {{ if eq .type "textarea" }}
      <textarea name="{{ .name }}" {{ if .required }}required{{ end }}></textarea>
    {{ else }}
      <input type="{{ .type }}" name="{{ .name }}" {{ if .required }}required{{ end }}>
    {{ end }}
  </label>
{{ end }}

I like this because it turns a contact form into a single-file edit for anyone on the team. Overkill for a personal blog. Very nice for a small business site where the owner occasionally wants to add a "how did you hear about us" dropdown without filing a ticket.


Create a free form, paste the URL into your hugo.toml, rebuild, and you're done. Four minutes start-to-finish, deployable anywhere.

If you also run sites on other stacks, the exact same pattern works for Astro, Next.js, Webflow, and Framer. One endpoint, one dashboard, every framework.

← All posts