← All guides
Hugo

Hugo contact form without a server

Add a contact form to any Hugo site with one partial and a shortcode. Submissions are stored in the EU (Frankfurt), spam is filtered without a captcha.

Hugo builds static files, so nothing on your host can receive a form POST. You don't need to add a server: a plain HTML form posts to Formlane, and a shortcode drops it into any Markdown page.

Steps

  1. Sign in to Formlane, create a form and copy its access key (fl_…).
  2. Add the key to hugo.toml under [params] as formlane_key.
  3. Create the partial and the shortcode below, then put {{< contact-form >}} on your contact page.
  4. Create content/thanks.md, run hugo, deploy and send a test submission.

Code

hugo.toml
[params]
  formlane_key = "fl_YOUR_ACCESS_KEY"

The key is public by design: it only allows sending submissions to this one form. To be safe, restrict the form to your domain in its settings.

layouts/partials/contact-form.html
<form action="https://smallwerk.com/formlane/api/submit" method="POST" accept-charset="UTF-8">
  <input type="hidden" name="access_key" value="{{ site.Params.formlane_key }}">
  <input type="hidden" name="subject" value="New message from {{ site.Title }}">
  <input type="hidden" name="redirect" value="{{ "thanks/" | absURL }}">
  <!-- Honeypot: hidden from people, leave it unchecked -->
  <input type="checkbox" name="botcheck" style="display:none" tabindex="-1">

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

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

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

  <button type="submit">Send</button>
</form>
layouts/shortcodes/contact-form.html
{{ partial "contact-form.html" . }}
content/contact.md
---
title: "Contact"
---

Questions? Write us.

{{< contact-form >}}
content/thanks.md
---
title: "Thanks"
---

Thanks for your message. We'll get back to you soon.

The thank-you page

After a successful submission the redirect field sends visitors to /thanks/. It has to be an absolute http(s) URL; absURL builds one from your baseURL in hugo.toml. Without a redirect, Formlane shows its own hosted thank-you page.

Optional: stay on the page

If you'd rather show a message without a page change, send the form with fetch(). With Accept: application/json, Formlane answers {"success": true, "message": "Form submitted successfully", "id": "…"} or {"success": false, "error": "limit_reached", "message": "…"} with a 4xx/5xx status. message is always English, so the snippet below shows its own text per error code.

layouts/partials/contact-form.html (append)
<script>
  const MESSAGES = {
    limit_reached: "This form can't take more messages this month. Please email us directly.",
    rate_limited: "Too many messages in a short time. Please try again in a few minutes.",
  };
  const form = document.querySelector('form[action*="formlane"]');
  if (form) form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const res = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const json = await res.json().catch(() => ({}));
    // Spam caught by the honeypot or the content rules also gets success: true.
    form.outerHTML = json.success
      ? "<p>Thanks! We'll get back to you.</p>"
      : "<p>" + (MESSAGES[json.error] || "Sorry, that didn't work. Please try again later.") + "</p>";
  });
</script>

Spam protection without a captcha

  • Honeypot: the hidden botcheck checkbox. If a bot ticks it, Formlane returns a normal success response and stores nothing.
  • Rate limit: 10 submissions per 10 minutes per visitor and form.
  • Content rules: messages with 4 or more links, link markup or typical spam phrases go to a separate spam tab. Spam doesn't count toward your monthly limit.
  • Allowed domains: limit the form to your own domain in its settings.

Where the data is stored

Submissions are stored in the EU (Frankfurt), in a Supabase database in the eu-central-1 region. The app runs on Vercel with compute in Frankfurt; static assets and the edge network are global. All providers are listed in the privacy policy. Formlane is run by Mindcaptain Services OÜ in Tallinn, Estonia. A data processing agreement is included in the Terms (Art. 28 GDPR, smallwerk.com/dpa), and IP hashes are deleted after 30 days. Add the contact form to your own privacy policy, and delete old submissions in the dashboard when you no longer need them.

Pricing

Free: 250 submissions a month and up to 5 forms. Starter: $8/month for 2,500 submissions. Pro: $19/month for 25,000 submissions.

FAQ

Does this work on GitHub Pages, Netlify and Cloudflare Pages?

Yes. Those hosts only serve your static files; the form posts directly to Formlane.

Do I need to enable unsafe HTML in Goldmark?

No. The form lives in a partial and reaches your Markdown through a shortcode, so markup.goldmark.renderer.unsafe can stay off.

My theme already has a Formspree or Netlify form. Can I keep its markup?

Usually yes. Change the form's action to https://smallwerk.com/formlane/api/submit and add the hidden access_key input. The field names email and subject and the honeypot _gotcha work as they are. Netlify attributes such as data-netlify or netlify-honeypot can be removed.

Will I get an email for each message?

Yes. Every message that isn't flagged as spam is emailed to you, with reply-to set to the sender's address. It also shows up in your Formlane dashboard, where you can export it as CSV.

Can visitors upload files?

Not yet. Formlane accepts text fields only.

What happens at the monthly limit?

New submissions are rejected with a limit_reached error until the next calendar month or until you upgrade. Submissions you already have are kept.

Get your access key

Free for 250 submissions a month. Data stored in the EU (Frankfurt).

Get your access key