How to Add a Contact Form to a Hugo Site (No Backend Required)

8 min read
Static Forms Team

Hugo builds blazing-fast static sites in seconds, which is exactly why it's a favorite for blogs, docs, and marketing sites. But that speed comes from generating plain HTML at build time — there's no running server to catch a form submission. So the first time a visitor wants to email you through a contact page, you hit a wall: Hugo has no built-in way to process forms.

The clean solution is to post your form to a form backend that handles validation, spam filtering, and email delivery for you. In this tutorial you'll add a working contact form to a Hugo site using Static Forms, package it as a reusable partial and shortcode, and wire it up so it works with any theme — including popular ones like PaperMod and Ananke.

Why Hugo needs a form backend

Hugo's whole model is "render everything ahead of time." When someone hits your site, they're downloading static files from a CDN — fast and secure, but with nothing on the other end to receive a POST. A form backend fills that gap by giving you a single endpoint to submit to. Hugo's job is just to render the <form>; the backend does the rest.

One partial, reused everywhere
layouts/partials/
contact-form.html
Define the form once
Contact page
Footer
Any blog post (shortcode)
📬
Static Forms API
→ your inbox

Prerequisites

You'll need:

Step 1: Store your API key in site config

Keep the key in one place so you never hard-code it across templates. Add it to hugo.toml (or config.toml on older Hugo versions):

TOML
[params]
  staticFormsKey = "YOUR_API_KEY_HERE"

This is a public form key — it ships in your site's HTML like any client-side form field. It only authorizes submissions to your account, so it's safe to commit.

Step 2: Create a reusable partial

Hugo partials are the equivalent of includes. Create layouts/partials/contact-form.html:

GO-HTML-TEMPLATE
<form action="https://api.staticforms.dev/submit" method="POST" class="contact-form">
  <input type="hidden" name="apiKey" value="{{ .Site.Params.staticFormsKey }}" />
  <input type="hidden" name="replyTo" value="@" />
  <input type="hidden" name="subject" value="New message from {{ .Site.Title }}" />

  <label for="name">Name</label>
  <input type="text" id="name" name="name" required />

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

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

  <!-- Honeypot: hidden from people, irresistible to bots -->
  <input type="text" name="honeypot" style="display:none" tabindex="-1" autocomplete="off" />

  <button type="submit">Send message</button>
</form>

The key fields:

  • apiKey pulls your key from site config and routes the submission to your account.
  • replyTo set to @ uses the visitor's email as the reply-to address, so you reply right from your inbox.
  • honeypot silently drops bot submissions — bots fill every field, humans never see this one.

Step 3: Add a contact page

Create content/contact.md. Hugo doesn't render raw HTML inside Markdown by default, so the cleanest approach is to call the partial from a template. If your theme has a single-page layout you can extend, add the partial there; the most portable route is a dedicated layout. Create layouts/page/contact.html:

GO-HTML-TEMPLATE
{{ define "main" }}
  <article>
    <h1>{{ .Title }}</h1>
    {{ .Content }}
    {{ partial "contact-form.html" . }}
  </article>
{{ end }}

Then set the layout in the page's front matter:

YAML
---
title: "Contact"
layout: "contact"
---

Have a question? Drop me a line below.

Step 4: Use it anywhere with a shortcode

To let yourself (or other authors) embed the form inside any Markdown post, wrap the partial in a shortcode. Create layouts/shortcodes/contact.html:

GO-HTML-TEMPLATE
{{ partial "contact-form.html" . }}

Now any content file can include the form inline:

MARKDOWN
Want to work together?

{{</* contact */>}}

Step 5: Theme-specific notes

The partial works with any theme, but a couple of popular ones have a natural home for it:

  • PaperMod — PaperMod ships with a single.html layout. The simplest path is the dedicated layouts/page/contact.html above, which overrides the theme for just that page. Don't edit files inside themes/ directly; put your overrides in your project's root layouts/ so theme updates don't clobber them.
  • Ananke — Ananke renders page content through .Content. The dedicated layout approach works the same way. If you'd rather keep everything in Markdown, enable raw HTML by adding [markup.goldmark.renderer] with unsafe = true to your config — but the partial-and-shortcode approach keeps your content clean and avoids that flag.

Step 6: Build, deploy, and test

Run your normal build and deploy — Hugo sites work the same on Netlify, Cloudflare Pages, GitHub Pages, or any static host:

Bash
hugo --minify

Open your contact page on the deployed site and send a test message. The first submission triggers a one-time verification email from Static Forms — click the link, and every submission afterward flows straight to your inbox.

By default the API returns a plain success response. To send visitors to a thank-you page instead, add a redirect field to the partial:

GO-HTML-TEMPLATE
<input type="hidden" name="redirectTo" value="{{ .Site.BaseURL }}thank-you/" />

The full field reference lives in the Static Forms documentation.

Step 7: Style the form to match your theme

The partial inherits your theme's styles, but a contact form usually deserves a bit of intention. Add a stylesheet at assets/css/contact-form.css and pull it into your form layout, or drop the rules into your theme's main stylesheet:

CSS
.contact-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  max-width: 32rem;
}

.contact-form input,
.contact-form textarea {
  width: 100%;
  padding: 0.65rem 0.75rem;
  font: inherit;
  border: 1px solid #cbd5e1;
  border-radius: 6px;
}

.contact-form input:focus,
.contact-form textarea:focus {
  outline: 2px solid #7c3aed;
  outline-offset: 1px;
  border-color: transparent;
}

.contact-form button {
  align-self: flex-start;
  padding: 0.65rem 1.25rem;
  font: inherit;
  font-weight: 600;
  color: #fff;
  background: #7c3aed;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}

If you're on a recent Hugo with the asset pipeline, fingerprint and link it from your form layout:

GO-HTML-TEMPLATE
{{ with resources.Get "css/contact-form.css" | minify | fingerprint }}
  <link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" />
{{ end }}

Submit without leaving the page (AJAX)

The plain form works with zero JavaScript. If you'd rather keep visitors on the page, submit with fetch(). Create a small script — for example assets/js/contact-form.js — and include it from your form layout:

JavaScript
const form = document.querySelector('.contact-form');

form?.addEventListener('submit', async (event) => {
  event.preventDefault();
  const data = Object.fromEntries(new FormData(form).entries());

  try {
    const response = await fetch('https://api.staticforms.dev/submit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ...data, replyTo: '@' }),
    });

    const result = await response.json();

    if (result.success) {
      form.outerHTML = '<p role="status">Thanks — your message is on its way!</p>';
    } else {
      throw new Error(result.message);
    }
  } catch {
    form.insertAdjacentHTML(
      'beforeend',
      '<p role="alert">Something went wrong. Please try again.</p>'
    );
  }
});

Because the apiKey is already a hidden field in the partial, new FormData(form) picks it up automatically — no need to repeat it in the script. The honeypot rides along in the payload too, so spam protection still applies.

Multilingual contact forms

Hugo's first-class multilingual support is one of its biggest draws, and your contact form should follow suit. Move the user-facing strings into your i18n translation files (i18n/en.toml, i18n/fr.toml, and so on):

TOML
# i18n/en.toml
[contactName]
other = "Name"
[contactMessage]
other = "Message"
[contactSend]
other = "Send message"

Then reference them in the partial with the i18n function:

GO-HTML-TEMPLATE
<label for="name">{{ i18n "contactName" }}</label>
<input type="text" id="name" name="name" required />

<textarea name="message" placeholder="{{ i18n "contactMessage" }}" required></textarea>
<button type="submit">{{ i18n "contactSend" }}</button>

The same single endpoint handles every language — only the labels change. You can even set a per-language email subject so you know which site version a message came from.

Going further

  • Spreadsheets and tools. Forward submissions to a sheet with the Google Sheets integration, or send them anywhere via webhooks.
  • AJAX submission. Prefer an inline success message over a page navigation? Submit with fetch() — the pattern is the same one we use in the JAMstack contact form guide.
  • Other generators. Using more than one static site generator? We have matching tutorials for Astro and other frameworks.

Troubleshooting

The most common snags on a Hugo setup:

  • partial "contact-form.html" not found. The partial must live at layouts/partials/contact-form.html in your project root (not inside themes/). Hugo resolves project-level layouts first, which is also how you safely override a theme.
  • Raw HTML in Markdown is being stripped. Hugo's Goldmark renderer drops raw HTML by default for safety. That's why this guide uses a partial and a shortcode instead of pasting <form> into a .md file. If you must inline HTML, set markup.goldmark.renderer.unsafe = true in your config — but the partial approach is cleaner.
  • The form submits but no email arrives. Confirm the action is exactly https://api.staticforms.dev/submit and your key is correct, then check for the one-time verification email (including spam) and click its link.
  • The shortcode prints literally instead of rendering. Make sure you're using the shortcode syntax with percent or angle brackets — {{</* contact */>}} — and that the file is at layouts/shortcodes/contact.html.
  • Styles aren't applying. If you use the asset pipeline snippet, confirm the CSS file is under assets/ (not static/) — resources.Get only reads from assets/.

Frequently asked questions

Does this work with any Hugo theme?
Yes. The partial and shortcode live in your project root, so they work regardless of theme and survive theme updates.

Is the API key safe in the HTML?
Yes — it's a public form key that only authorizes submissions to your account. It ships in the page source like any client-side form field and can't be used to read your data.

Can I have more than one form on a site?
Absolutely. Reuse the same partial anywhere, and set a different subject value per form so you can tell submissions apart in your inbox.

What does it cost?
A free plan covers typical personal and small-business use. For higher volume, file uploads, or advanced spam controls, see the pricing page.

Wrapping up

Hugo doesn't process forms, and it doesn't need to. Define the form once as a partial, expose it as a shortcode, and post to a form backend that handles validation, spam, and email. You keep Hugo's speed and simplicity, and your visitors get a contact form that just works — across every theme.

Create a free Static Forms account and add a contact form to your Hugo site today.