Building a Headless Contact Form with Nuxt 3 on Cloudflare Pages
Build a Nuxt 3 contact form on Cloudflare Pages with no backend. Reusable Vue component, Zod + VeeValidate validation, email + Slack automation. 600 free submissions/month.
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.
layouts/shortcodes/contact-form.html file gives you a reusable {{< contact-form >}} tag for any page or post.python -m http.server for local previews. 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.
Before we wire anything up, here is the honest comparison most blog posts skip. These are the three answers you will keep hearing.
| Option | Free Tier | Host Lock-in | Customization |
|---|---|---|---|
| Netlify Forms | 100 submissions / site / month | Netlify only | Limited (Netlify dashboard) |
| Formspree | 50 submissions / month | Vendor host (URL is theirs) | Good, but routing is paid |
| Google Forms iframe | Unlimited | Google-branded UI | Almost none |
| CustomJS form hook | 600 / month, all sites | None (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.
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 URLThe 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.
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.
Add the hook ID to your site config so individual pages do not need to know it:
# hugo.toml
[params]
contactHookId = "abc123def456" 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.
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.
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.
Spam is the part most form tutorials gloss over. Three layers, in order of how much friction they add for real users:
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;
}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
}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.
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.
The webhook is plain JSON. Anything that speaks HTTP can pick it up. The two integrations most Hugo teams reach for first:
hugo.toml.name, email, message, and source into a CRM module (HubSpot, Pipedrive, Airtable, take your pick).source so submissions from /pricing/ hit your sales pipeline and submissions from /contact/ go to support.Full walkthrough in our Make.com integration guide.
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.
A short, honest comparison once you have all three running.
| Feature | Netlify Forms | Formspree | Hugo + CustomJS |
|---|---|---|---|
| Free submissions / month | 100 (site limit) | 50 (account limit) | 600 (account limit) |
| Works on GitHub Pages? | No | Yes | Yes |
| Works on Cloudflare Pages? | No | Yes | Yes |
| PDF receipt in same call | No | No | Yes |
| Webhook routing per source | Paid tier | Paid tier | Free |
| Lock-in if you move host | Full rewrite | URL stays | Zero, 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.
A few things that bite people on this setup. Worth knowing before you ship.
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.
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.
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.
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.
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:
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.
Yes. GitHub Pages serves the rendered HTML, and the browser POSTs to the CustomJS hook. There is no GitHub Action or server runtime required.
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.
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.
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.
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.
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.
Yes. The shortcode lives in your project, not the theme. Themes never get touched, and a theme upgrade will not stomp the file.
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
Continue reading on similar topics
Build a Nuxt 3 contact form on Cloudflare Pages with no backend. Reusable Vue component, Zod + VeeValidate validation, email + Slack automation. 600 free submissions/month.
Build a React contact form on S3 + CloudFront with no AWS Lambda, API Gateway, or SES. Typed react-hook-form + Zod, PDF auto-reply, edge CORS and CSP, cost table at 1k submissions.
Discover what headless forms enable in 2026: pure frontend stacks, custom multi-step flows, API-first marketing automation, and programmatic form generation. 600 free submissions/month.