Blog

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

Hugo is famously fast, famously simple, and famously server-less. You write Markdown, run hugo, and get a folder of static HTML you can drop on any CDN. Then your client asks for a contact form, and the whole story falls apart, because Hugo cannot, by definition, receive a POST request.

Most tutorials answer this by reaching for a vendor-specific form handler. Netlify Forms is the easy one if you happen to host on Netlify. Formspree is fine until you hit the free tier wall. Google Forms looks like 2008. Each of these locks your form into one place and bills you per submission once you outgrow the free plan.

There is a calmer option: a tiny Hugo shortcode that posts straight to a hosted form API. No backend, no vendor host lock-in, no netlify.toml magic. This guide walks through the whole setup, with copy-paste shortcodes, spam protection, an HTML email autoresponder, and a PDF receipt the user can download after submitting.

TL;DR

  • Hugo ships zero server runtime, so the contact form lives entirely in the browser and POSTs to an external API.
  • A single layouts/shortcodes/contact-form.html file gives you a reusable {{< contact-form >}} tag for any page or post.
  • Spam is handled with a hidden honeypot field plus an optional reCAPTCHA v3 score check, both at zero cost.
  • The CustomJS form hook accepts the submission, fires a webhook into Make.com or n8n, and can email the visitor a PDF receipt in the same request.
  • Free tier covers 600 submissions per month, no credit card, and the same endpoint works on any static host: GitHub Pages, Cloudflare Pages, S3, even python -m http.server for local previews.

Why a Hugo Site Cannot Handle a Form on Its Own

Hugo is a static site generator. The output of hugo --minify is a directory of HTML, CSS, JavaScript, and images. There is no Node process, no PHP interpreter, no request handler. When a visitor hits /contact/, the CDN streams a file back. That is the entire interaction.

A form submission, by contrast, needs something on the other end to read the request body, validate it, store it, and trigger a follow-up. That "something" has to live outside Hugo. The cleanest way to add it without rebuilding your stack is to keep the form in your Hugo theme, but point the submit URL at a hosted form endpoint that handles everything past the network boundary.

This is the same pattern documented in our pillar guide on serverless static site hosting: keep the site static, push the dynamic bits to a small API.

The Three Options Most Hugo Tutorials Suggest (and Their Trade-offs)

Before we wire anything up, here is the honest comparison most blog posts skip. These are the three answers you will keep hearing.

OptionFree TierHost Lock-inCustomization
Netlify Forms100 submissions / site / monthNetlify onlyLimited (Netlify dashboard)
Formspree50 submissions / monthVendor host (URL is theirs)Good, but routing is paid
Google Forms iframeUnlimitedGoogle-branded UIAlmost none
CustomJS form hook600 / month, all sitesNone (any static host)Full (your own HTML, your own CSS)

The CustomJS row is what we will build below. The form lives in your Hugo theme, looks exactly the way you want it, and posts to a URL that does not care whether you deploy to Netlify, Cloudflare Pages, GitHub Pages, or an S3 bucket behind CloudFront.

Architecture in One Diagram

What the visitor does, and what each piece of the stack is responsible for:

[ Hugo static page ]
        โ”‚  (rendered at build time, served from CDN)
        โ–ผ
  <form> submit (fetch POST, JSON body)
        โ”‚
        โ–ผ
[ https://hook.customjs.io/<your-id> ]
        โ”‚  validates honeypot, stores submission, returns 200
        โ”œโ”€โ”€โ–บ Webhook fan-out (Make.com / n8n / Zapier scenario)
        โ”œโ”€โ”€โ–บ SMTP email to you + autoresponder to visitor
        โ””โ”€โ”€โ–บ Optional PDF receipt generated and returned as URL

The Hugo build never changes when you tweak the workflow on the other side. You can swap the Make.com scenario, change the autoresponder template, or add a Slack notification step without redeploying the site.

Step 1: Create the Hugo Shortcode

Shortcodes are the right tool here. They give you one snippet you can drop into any Markdown page (contact.md, the bottom of a blog post, a landing page) without copy-pasting the HTML each time.

Create the file layouts/shortcodes/contact-form.html in your Hugo project:

{{/* layouts/shortcodes/contact-form.html */}}
{{- $hookId := .Get "hook" | default site.Params.contactHookId -}}
{{- $endpoint := printf "https://hook.customjs.io/%s" $hookId -}}

<form
  class="cjs-contact-form"
  data-endpoint="{{ $endpoint }}"
  autocomplete="on"
>
  <label>
    <span>Name</span>
    <input type="text" name="name" required minlength="2" />
  </label>

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

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

  <!-- Honeypot: real users never see this, bots always fill it -->
  <label class="cjs-hp" aria-hidden="true">
    Leave this empty
    <input type="text" name="website" tabindex="-1" autocomplete="off" />
  </label>

  <button type="submit">Send message</button>
  <p class="cjs-status" role="status" aria-live="polite"></p>
</form>

Two details worth pointing out. The $hookId falls back to a site-wide parameter, so you can configure the hook once in hugo.toml and reuse the shortcode across multiple pages. And the honeypot website field is hidden with CSS, not with type="hidden", because spam bots actively skip hidden inputs but happily fill anything labelled.

Configure the hook ID once

Add the hook ID to your site config so individual pages do not need to know it:

# hugo.toml
[params]
  contactHookId = "abc123def456"

Step 2: The Submit Handler (Vanilla JS, No Build Step)

Hugo themes deal with assets in assets/js/ and pipe them through resources.Get. Drop the handler there so it gets minified and fingerprinted automatically.

Create assets/js/contact-form.js:

// assets/js/contact-form.js
document.querySelectorAll('.cjs-contact-form').forEach((form) => {
  const status = form.querySelector('.cjs-status');
  const button = form.querySelector('button[type="submit"]');
  const endpoint = form.dataset.endpoint;

  form.addEventListener('submit', async (event) => {
    event.preventDefault();

    // Honeypot check, do not even send the request
    const honeypot = form.querySelector('input[name="website"]');
    if (honeypot && honeypot.value.trim() !== '') {
      status.textContent = 'Thanks, we will be in touch.';
      form.reset();
      return;
    }

    const data = Object.fromEntries(new FormData(form).entries());
    delete data.website;

    button.disabled = true;
    status.textContent = 'Sending...';

    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          source: location.pathname,
          ...data,
        }),
      });

      if (!response.ok) throw new Error('Bad response: ' + response.status);

      status.textContent = 'Thanks! We will reply within one business day.';
      form.reset();
    } catch (err) {
      console.error(err);
      status.textContent = 'Something went wrong. Please email [email protected].';
    } finally {
      button.disabled = false;
    }
  });
});

Wire it into your base template (layouts/_default/baseof.html) the Hugo way:

{{ $contactJs := resources.Get "js/contact-form.js" | js.Build | minify | fingerprint }}
<script src="{{ $contactJs.RelPermalink }}" defer></script>

The script is around 35 lines once minified. No npm install, no framework, no Webpack. It runs the moment the page is interactive.

Step 3: Use the Shortcode in Any Page

Once the shortcode and the handler exist, dropping a contact form anywhere is a single line. Open content/contact.md:

---
title: "Contact"
description: "Get in touch with the team."
---

We reply within one business day.

{{< contact-form >}}

Need a second form on a campaign landing page with a different hook ID? Pass the parameter:

{{< contact-form hook="xyz789campaign" >}}

That covers the full client-side story. From here, every choice is about the workflow behind the hook.

Try the Same Pattern Live

The widget below is the same form shape the shortcode renders, talking to the same API. Submit it to see the round trip without leaving the page.

Want to see the form builder dashboard? Check the Form Builder product page, or jump into the native API documentation if you would rather call the endpoint directly.

Stopping Spam Without Adding a Captcha Wall

Spam is the part most form tutorials gloss over. Three layers, in order of how much friction they add for real users:

1. Honeypot field (already wired)

The hidden website input above filters out the majority of dumb crawlers. The CSS for it is the smallest possible:

.cjs-hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

2. Time-on-form check

Bots submit instantly. Add a tiny timestamp before the form mounts and reject submissions faster than two seconds. The server side rejects them silently so the bot does not learn anything.

const mountedAt = Date.now();
// ...inside submit handler:
const elapsed = Date.now() - mountedAt;
if (elapsed < 2000) {
  status.textContent = 'Thanks, we will be in touch.';
  return; // silently drop
}

3. reCAPTCHA v3 (optional, score-based)

For high-traffic public pages, reCAPTCHA v3 runs invisibly and returns a score from 0.0 to 1.0. Add the token to the payload, validate server-side. The user never sees a challenge unless their score is very low.

const token = await grecaptcha.execute(SITE_KEY, { action: 'contact' });
const payload = { ...data, recaptchaToken: token };

For 95% of Hugo sites, layers 1 and 2 are plenty. Add layer 3 only after you see real spam volume.

Deliver the Submission as Email + PDF Receipt

One reason teams pick a hosted form API over Netlify Forms is that the same request can do more than store data. CustomJS lets you forward the submission to email and generate a PDF on the side, all in one call.

Here is a minimal HTML email template you can render server-side using the same submission payload. Save it as a stored function or pass it inline to the HTML to PDF endpoint documented at https://e.customjs.io/html2pdf:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Contact receipt</title>
    <style>
      body { font-family: 'Inter', system-ui, sans-serif; color: #1f2937; padding: 40px; }
      h1 { color: #4f46e5; border-bottom: 3px solid #4f46e5; padding-bottom: 8px; }
      .field { margin: 16px 0; }
      .label { font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
      .value { font-size: 16px; margin-top: 4px; }
    </style>
  </head>
  <body>
    <h1>Thanks, {{ name }}</h1>
    <p>We received your message on {{ submittedAt }}. A copy is attached for your records.</p>
    <div class="field">
      <div class="label">Email</div>
      <div class="value">{{ email }}</div>
    </div>
    <div class="field">
      <div class="label">Message</div>
      <div class="value">{{ message }}</div>
    </div>
  </body>
</html>

The same payload that arrived from the Hugo form drives the template. No double bookkeeping, no second submit step.

Wire the Form into Make.com or n8n

The webhook is plain JSON. Anything that speaks HTTP can pick it up. The two integrations most Hugo teams reach for first:

Make.com scenario

  1. Add the CustomJS module, choose Watch Form Submission, and pick the hook ID you put in hugo.toml.
  2. Map name, email, message, and source into a CRM module (HubSpot, Pipedrive, Airtable, take your pick).
  3. Add a Gmail or SMTP step for the autoresponder.
  4. Optionally, branch on source so submissions from /pricing/ hit your sales pipeline and submissions from /contact/ go to support.

Full walkthrough in our Make.com integration guide.

n8n workflow

Same idea, different UI. The n8n node ships with a credential setup for your CustomJS API key, then exposes a Form Submission trigger you can chain with any of n8n's 400+ nodes. For multi-step approval flows, pair it with our guide on human-in-the-loop workflows in n8n.

See the n8n integration documentation for credential setup and node parameters.

Comparison: This Pattern vs Netlify Forms and Formspree

A short, honest comparison once you have all three running.

FeatureNetlify FormsFormspreeHugo + CustomJS
Free submissions / month100 (site limit)50 (account limit)600 (account limit)
Works on GitHub Pages?NoYesYes
Works on Cloudflare Pages?NoYesYes
PDF receipt in same callNoNoYes
Webhook routing per sourcePaid tierPaid tierFree
Lock-in if you move hostFull rewriteURL staysZero, same endpoint

The point is not that Netlify Forms is bad. If you are already on Netlify and 100 submissions a month is enough, it is the path of least resistance. The CustomJS pattern earns its keep the moment you outgrow that, or the moment a client moves the site from Netlify to Cloudflare Pages and you do not want to rewrite your form layer.

Common Pitfalls

A few things that bite people on this setup. Worth knowing before you ship.

CORS errors during local dev

If you run hugo server on localhost:1313 and the browser blocks the request, your hook ID is fine. The CustomJS form hook accepts requests from any origin by default, but corporate proxies sometimes strip the Content-Type header. Check the Network tab and confirm the request body is JSON, not multipart/form-data.

Hugo escapes my JS

Inline JavaScript in a shortcode goes through Hugo's HTML escaping by default. Keep the handler in assets/js/ rather than inlining it. The example above does exactly that on purpose.

Multiple forms, one page

The handler iterates document.querySelectorAll('.cjs-contact-form') on purpose, so you can have a header newsletter form and a footer contact form on the same page and they each post to their own hook. Just pass different hook= parameters in the shortcode call.

Cache busting after a theme change

The | fingerprint step in the asset pipeline above produces a hashed filename. If you skip it, visitors hit a cached old contact-form.js for up to a week. Keep the fingerprint.

Where This Goes Next

The same shortcode pattern scales to newsletter signups, quote requests, event RSVPs, and bug reports. Two follow-ups worth reading once the contact form is live:

Frequently Asked Questions

1. Do I need a backend to add a contact form to Hugo?

No. The Hugo site stays static. The form posts to an external HTTPS endpoint that handles storage, webhooks, and email. You never run a server yourself.

2. Will this work on GitHub Pages?

Yes. GitHub Pages serves the rendered HTML, and the browser POSTs to the CustomJS hook. There is no GitHub Action or server runtime required.

3. Can I keep my existing Netlify Forms setup and try this alongside?

Yes. Render two forms (or A/B them with a build flag) and watch the submission counts. Most teams move fully once they see the routing flexibility.

4. How do I customize the success state?

The handler writes to .cjs-status. Replace that line with anything: a confetti animation, a redirect, a modal. The submission is already through by the time that line runs.

5. Is the free tier really 600 submissions per month?

Yes, per account, not per site. So if you maintain three client sites with one CustomJS account, the 600 is shared across all of them, no extra plan to manage.

6. What about GDPR and the visitor's email address?

The submission lives in the CustomJS dashboard under your account and is purgeable any time. Most teams pair it with a one-line privacy notice next to the submit button: where the data goes, who can read it, how long it is kept.

7. Can I add file uploads (a CV, a screenshot, a logo)?

Yes, but it changes the request shape. Upload directly to S3 or Cloudinary first, then include the resulting URL in the JSON payload. The form hook is JSON-only by design, which keeps the schema clean for the downstream webhook.

8. Does it work with the Hugo themes I already use (Ananke, PaperMod, Doks)?

Yes. The shortcode lives in your project, not the theme. Themes never get touched, and a theme upgrade will not stomp the file.

Wrap-up

A Hugo contact form does not need a backend, a Lambda, or a vendor host. It needs a tiny shortcode, 35 lines of vanilla JS, and a URL you point the request at. From there, every workflow piece (the autoresponder, the CRM update, the PDF receipt, the Slack ping) lives in a Make.com or n8n scenario you can change without ever rebuilding the site.

The CustomJS free tier covers 600 submissions a month with no credit card, which is enough for most marketing sites, freelance portfolios, and SaaS landing pages. When you scale past it, the same endpoint keeps working and the Hugo side never changes.

Grab a hook ID and ship today

Related Articles

Continue reading on similar topics