Eleventy contact forms: the four-minute setup I use on every site
April 4, 2026 · 5 min read
Eleventy is the SSG I keep coming back to after every experiment with something shinier. It's small. It doesn't invent new concepts. It lets me write plain HTML when I want plain HTML. And it has never, not once, surprised me in a bad way at build time.
The flip side is that 11ty is deliberately un-opinionated about everything that isn't generating pages. Forms? Not its job. Submissions? Not its job. You handle that.
Which is fine, because the setup takes four minutes and I've pasted it into so many sites that I can almost type it with my eyes closed. Here it is, written down for the next time I forget.
What we're building
A /contact/ page on an 11ty site with:
- A real HTML form (no JS framework, no hydration, no build step)
- A hidden honeypot field for spam
- A custom thank-you page
- Email and webhook notifications handled by a hosted backend
- Zero 11ty plugins
Step 1 — The endpoint
Sign up somewhere that hosts form endpoints. I'll use FormTo for this post. You'll end up with a URL like:
https://api.formto.dev/f/your-form-slug
Copy it. Paste it into .eleventy.js or your site data file so it's configurable:
// _data/site.js
module.exports = {
title: "Your Site",
contactEndpoint: "https://api.formto.dev/f/your-form-slug",
thanksUrl: "/thanks/",
}
Now anywhere in your templates you can reference {{ site.contactEndpoint }}. Swapping endpoints later means touching one file.
Step 2 — The contact page
Create contact.njk (or .liquid, or .md with front matter — all three work):
---
layout: base.njk
title: Contact
permalink: /contact/
---
<main class="contact">
<h1>Say hi</h1>
<p>Real replies from real humans, usually within a day.</p>
<form method="POST" action="{{ site.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 — hidden from humans, irresistible to bots #}
<input type="text" name="website" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px" aria-hidden="true">
<input type="hidden" name="_redirect"
value="{{ site.url }}{{ site.thanksUrl }}">
<button type="submit">Send</button>
</form>
</main>
Save. Run npx @11ty/eleventy --serve. Open http://localhost:8080/contact/. You should see the form.
Step 3 — The thank-you page
Create thanks.njk:
---
layout: base.njk
title: Thanks
permalink: /thanks/
---
<main class="thanks">
<h1>Got it.</h1>
<p>We'll reply within a day or two.</p>
<a href="/">← Back home</a>
</main>
That's the whole thank-you flow. The _redirect field in the form tells the backend where to send the browser after a successful post.
Step 4 — Notifications
Back in the backend dashboard:
- Add your email under notifications. First submission lands in your inbox.
- Optional: paste a Slack webhook URL into the webhook field for instant pings.
- Optional: wire the webhook to n8n, Make, or a spreadsheet if you want submissions in a sheet.
You now have a working contact form. Commit, deploy, done.
The 11ty-specific upgrade I always end up doing
Once I have three or four forms on a site — contact, newsletter, "request a quote," "join the waitlist" — I pull the form into a shortcode so I don't repeat myself. In .eleventy.js:
module.exports = function (eleventyConfig) {
eleventyConfig.addShortcode("form", function (formSlug, redirectPath) {
const endpoint = `https://api.formto.dev/f/${formSlug}`
const redirect = redirectPath || "/thanks/"
return `
<form method="POST" action="${endpoint}">
<input type="text" name="website" tabindex="-1"
autocomplete="off" style="position:absolute;left:-9999px"
aria-hidden="true">
<input type="hidden" name="_redirect" value="${redirect}">
${this.ctx.content || ""}
</form>
`
})
}
Then in any template:
{% form "contact-slug", "/thanks/" %}
<label>Name <input name="name" required></label>
<label>Email <input type="email" name="email" required></label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
{% endform %}
The honeypot and redirect are now enforced automatically on every form across the site. Newer-you will never forget them again.
Why 11ty makes this especially nice
Here's the thing I love about 11ty for this particular problem. Because 11ty doesn't touch the rendered HTML at runtime, the form is just a form. No hydration mismatch. No "why is my form re-rendering on every keystroke" weirdness. No client JS required for basic submission. The page is pure HTML, the form is pure HTML, and the server handling the POST is somebody else's problem.
That's a pleasantly small amount of moving parts for something that, in a Next.js project, can quietly grow into a 200-line API route with a Resend key and a rate limiter you keep meaning to tune.
The one thing I always forget
The permalink: /contact/ trailing slash. I forget it, I deploy, the form ends up at /contact/index.html, and the relative paths in my thank-you redirect point at the wrong place. Every. Single. Time.
Write it down. Put it in your personal 11ty checklist. Or just keep this post open next time you scaffold a new site. I do.
Create a free form, put the URL in your _data/site.js, and your next 11ty project has a working contact page before you finish your coffee.
Running more than one framework? Same approach works on Astro, Next.js, Framer, Webflow, and Hugo.
More posts