Eleventy (11ty) contact form without a server
Add a contact form to an Eleventy site with one include and a global data file. No serverless function, honeypot spam filter, submissions stored in Frankfurt.
Eleventy builds static HTML. That is the point of it, but it also means nothing on your host can receive the POST when someone clicks "Send". The usual fixes are a host-specific feature (Netlify Forms only works when the site is deployed on Netlify) or your own serverless function plus an email provider.
With Formlane, the form posts straight from the browser to https://smallwerk.com/formlane/api/submit. Formlane stores the message and emails it to your account address, with reply-to set to the sender. It works on any host that serves your _site folder: GitHub Pages, Cloudflare Pages, Netlify, Vercel or your own server.
Quick start
- Sign in to Formlane, create a form and copy its access key (
fl_…). - Put the key and your site URL into
_data/site.json. - Create
_includes/contact-form.html(below) and add{% include "contact-form.html" %}to your contact page. - Create
thanks.md, runnpx @11ty/eleventy, deploy and send a test message. - In the form settings, restrict the form to your production domain, so forms on other websites can't post to it.
Code
{
"url": "https://example.com",
"title": "Example",
"formlaneKey": "fl_YOUR_ACCESS_KEY"
}The key is public by design: it only allows sending submissions to this one form, so it is fine in your repository and in the built HTML. Files in _data are global data in Eleventy, so site.formlaneKey is available in every template. If you already have a site data file, add the two keys there.
<form action="https://smallwerk.com/formlane/api/submit" method="POST" accept-charset="UTF-8">
<input type="hidden" name="access_key" value="{{ site.formlaneKey }}">
<input type="hidden" name="subject" value="New message from {{ site.title }}">
<input type="hidden" name="redirect" value="{{ site.url }}/thanks/">
<!-- 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>---
title: Contact
layout: base.njk
---
Questions? Write us.
{% include "contact-form.html" %}--- title: Thanks layout: base.njk --- Thanks for your message. We'll get back to you soon.
Use your own layout name instead of base.njk. By default Eleventy runs Markdown files through Liquid first, so the {% include %} tag works inside contact.md. Keep the quotes around the file name: Eleventy's Liquid setup expects them unless you changed dynamicPartials. In a Nunjucks page (contact.njk) the same line works unchanged.
The thank-you page
After a successful submission the redirect field sends visitors to /thanks/. It has to be an absolute http(s) URL, which is why the include builds it from site.url. Without a redirect, Formlane uses the default redirect from your form settings, or its own hosted thank-you page.
If your site lives under a sub-path (for example a GitHub Pages project site at https://username.github.io/repo-name/), set url to that full address including the path.
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 shows its own text per error code. Append it to _includes/contact-form.html:
<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>Without JavaScript the plain form still works, so the script is a progressive enhancement, not a requirement.
Spam protection without a captcha
- Honeypot: the hidden
botcheckcheckbox. 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.
Limits worth knowing
- Up to 64 KB per submission, 50 fields and 10,000 characters per field.
- Content types:
application/x-www-form-urlencoded,multipart/form-data(text fields only) andapplication/json. - File uploads are not supported yet. If your form needs attachments, use one of the alternatives below.
Alternatives, honestly compared
| Option | Good fit when | Trade-off |
|---|---|---|
| Formlane | Any host, plain HTML, you want email alerts and a dashboard without running code | No file uploads yet; on the Free plan, notification emails and the hosted thank-you page carry a "Powered by Formlane" link |
| Netlify Forms | Your Eleventy site is deployed on Netlify anyway | Only works on Netlify hosting; usage is billed in Netlify's credit plans |
| Formspree | You want the best-known form backend and its integrations | Free plan: 50 submissions a month (plans page, checked 26.10.2026) |
| Web3Forms | You need many forms on the free tier | Free plan: 250 submissions a month (checked 26.10.2026) |
| Your own serverless function (Netlify/Vercel/Cloudflare) | You want full control over storage and email | You maintain the function, an email provider account and spam protection yourself |
Where the data is stored
Stored in Frankfurt, in a Supabase database in the eu-central-1 region. The app runs on Vercel: our server functions run in Frankfurt (fra1); static files and edge routing are served by Vercel's global network. 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. Prices are in USD incl. VAT where applicable (also available in EUR). A read-only REST API and MCP server are included on every plan, including Free.
Related guides
FAQ
Does this work on GitHub Pages, Netlify, Cloudflare Pages and Vercel?
Yes. Those hosts only serve the files Eleventy writes to _site; the form posts directly to Formlane.
My starter (for example eleventy-base-blog) uses Nunjucks layouts. Does the include still work?
Yes. Put {% include "contact-form.html" %} in a Markdown or Nunjucks page. The include is plain HTML with three {{ }} variables, which Liquid and Nunjucks both understand.
My site already has a Netlify form. Can I keep its markup?
Usually yes. Change the form's action to https://smallwerk.com/formlane/api/submit, add the hidden access_key input and remove data-netlify, netlify-honeypot and the hidden form-name input. Rename Netlify's honeypot field (for example bot-field) to botcheck. The field names email and subject and the honeypot names botcheck, _gotcha and _honey work as they are.
Will I get an email for each message?
Yes, every submission that isn't spam is emailed to your account address, with reply-to set to the sender's email field, and stored in the dashboard.
Can I export the messages?
Yes, as CSV from the dashboard on every plan, or read them with the read-only REST API or the MCP server.
Get your access key
Free for 250 submissions a month. Submissions stored in Frankfurt.
Get your access key