← Blog

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.

HTML form (Eleventy output)
<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.

Nunjucks partial + site data
{# _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.com and bare example.com if 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:8080 during 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