Handle HTML forms in Eleventy
Add contact or signup forms to Eleventy — static HTML posts to Simple Signups, Nunjucks partial, exact domain allow-list for preview and prod.
Eleventy (11ty) is a static site generator: build time turns templates into HTML, CSS, and assets. The deployed site has no Node process waiting for form posts. A contact or newsletter form still needs an HTTPS action that accepts the submission, stores the email, and keeps bots out — without turning your jamstack host into an app server.
Eleventy forms do not need a Node server if the page only needs to collect contact or newsletter submissions. A plain HTML form can post to a hosted signup endpoint and keep the site fully static.
Static output still needs an external action
Relative actions that expect PHP, a same-origin API route, or a local mailto:habit break on pure static hosts (GitHub Pages, Cloudflare Pages, Netlify, S3, many CDNs). Eleventy does not invent a form backend. You either add serverless functions on the host, wire a third-party embed, or point plain HTML at a dedicated subscribe endpoint.
Common patterns for Eleventy forms
- Host serverless (Netlify Functions, Pages Functions, etc.). Full control — and you own validation, rate limits, spam, storage, and export.
- Host-tied form products. Fast on one platform; awkward when you move hosts or want the list under your own export path.
- Simple Signups. Campaign-scoped intake on a fixed HTTPS origin. Keep the 11ty site static; the form posts cross-origin to Simple Signups. That is signup/contact intake — not a general replacement for every server action you might run later.
Drop-in HTML form
Build-time HTML is enough. Set method="POST", point action at the Simple Signups subscribe URL, include the public campaign id (pub_…), and keep the honeypot field empty for humans.
<form action="https://simple-signups.com/api/subscribe" method="POST"> <input type="hidden" name="campaignId" value="pub_your_campaign_id" /> <input type="email" name="email" required placeholder="you@example.com" /> <input type="text" name="hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" /> <button type="submit">Send</button></form>Field reference and response shapes live in the docs quickstart.
Nunjucks include / partial
Most 11ty sites use Nunjucks (or Liquid). Put the form in an include, pass the endpoint and campaign id from global data or environment-backed config, and reuse the partial on contact, footer, and landing layouts.
{# _includes/signup-form.njk #}<form action="{{ simpleSignups.endpoint }}" method="POST"> <input type="hidden" name="campaignId" value="{{ simpleSignups.campaignId }}" /> <input type="email" name="email" required placeholder="you@example.com" /> <input type="text" name="hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" /> <button type="submit">{{ label | default("Send") }}</button></form>
{# In a layout or page #}{% include "signup-form.njk" %}
{# _data/simpleSignups.js (or .json) — example shape #}module.exports = { endpoint: "https://simple-signups.com/api/subscribe", campaignId: "pub_your_campaign_id",};Swap Liquid {% render %} or a JS template partial the same way — the browser only needs the final HTML fields. Prefer one public campaign per list so allow-list and export stay clean.
Exact domain allow-list: local, preview, production
Each campaign stores an exact list of allowed hostnames. On subscribe, the Worker checks browser Origin (or Referer) against that list. Matching is exact after normalization — no *.example.com wildcards, and apex does not automatically include www.
Plan entries for every host that will embed the form:
- Production — e.g.
www.example.comand bareexample.comif both serve the site - Preview / staging — the real preview hostname your CI or host assigns (often a unique subdomain). Add hosts you actually test from; there is no blanket preview wildcard
- Local — only if you POST from a browser origin such as
localhost:8080during development; omit it if you only smoke-test in preview
Empty allow-lists deny third-party embeds. First-party traffic on the Simple Signups product host is handled separately; cross-site embeds from your Eleventy deploy always need an explicit entry. Details: anti-abuse & domains.
What this does not replace
Simple Signups is the intake layer — domain gate, honeypot and rate limits, optional Turnstile, storage, export. It is not Eleventy middleware, a CMS, or a general multi-step app API. Keep build pipelines and content in 11ty; point marketing and contact forms at a dedicated campaign.
Related
- Contact forms use case — product-shaped guide for site contact and inquiry intake.
- HTML form submissions without PHP — host-agnostic static form pattern.
- Signups on Cloudflare Pages — same intake idea on a Pages deploy.
- Docs quickstart, path-style endpoint, and anti-abuse & domains.