splitforms.com
All articles/ TUTORIALS9 MIN READPublished May 10, 2026

Add a Contact Form to an Eleventy (11ty) Site 2026

Add a real contact form to your Eleventy (11ty) site in 2026 — no API route, no SMTP setup, Nunjucks template, and reliable email delivery for free.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Why Eleventy can't handle a contact form on its own

Eleventy (11ty) is a static site generator. When you run eleventy or eleventy --serve, it reads your Markdown, Nunjucks, Liquid, and data files, then writes plain HTML, CSS, and JS to dist/ (or _site/ by default). That output goes to Netlify, Cloudflare Pages, GitHub Pages, or any static host. There is no Node process running in production. No PHP. No Express. Nothing to receive a form POST.

So when someone hits Send on a contact form, the browser has to POST somewhere that is a server. You have three options:

  1. Write a serverless function (Netlify Functions, Cloudflare Workers, Vercel Edge). You maintain Node code, env vars, SMTP credentials, and rate limits. It works, but it's 4–6 hours of setup and ongoing maintenance.
  2. Use Netlify Forms. Free tier is 100 submissions/month, then it jumps to $19/month. It also locks you to Netlify — moving to Cloudflare Pages or another host kills the form.
  3. Point the form at a backend like splitforms. No function code. No SMTP. Host-agnostic. Free up to 1,000 submissions/month, $5/month after that. The rest of this guide does it this way.

If you're still picking, the 2026 form backend roundup compares splitforms, Formspree, Web3Forms, Getform, and Basin head-to-head.

Step 1: Grab a splitforms access key (1 minute)

You need one piece of authentication: an access key. It's a string the splitforms API uses to know which inbox the submission belongs to.

  1. Open splitforms.com/login.
  2. Enter your email, paste the 6-digit code that lands in your inbox.
  3. The dashboard generates an access key automatically. Copy it.

No credit card. No plan picker. The free tier (1,000 submissions/month) is the default and stays free forever. Save the key in a password manager — you're about to paste it into your Eleventy template.

Step 2: Build the Nunjucks include

The whole point of using an SSG is reuse. Don't paste the form HTML on every page — make it an include. Create the file _includes/contact-form.njk:

{# _includes/contact-form.njk #}
<form class="contact-form" action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
  <input type="hidden" name="subject" value="New message from {{ site.title }}" />
  <input type="hidden" name="redirect" value="{{ site.url }}/thanks/" />

  <label>
    Name
    <input type="text" name="name" required autocomplete="name" />
  </label>

  <label>
    Email
    <input type="email" name="email" required autocomplete="email" />
  </label>

  <label>
    Message
    <textarea name="message" rows="5" required></textarea>
  </label>

  {# Honeypot — invisible to humans, irresistible to bots #}
  <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />

  <button type="submit">Send message</button>
</form>

A couple of things to notice. The {{ site.title }} and {{ site.url }} tokens read from _data/site.json (or whatever global data file you've set up). That means the subject line and redirect URL stay correct even if you change your site name. The honeypot stops about 95% of automated spam without any visible captcha.

Replace YOUR_ACCESS_KEY with the key from Step 1. If you'd rather not commit it in plain text, put it in _data/site.json and reference {{ site.splitformsKey }} — but understand the key is meant to be public (it's tied to your domain, not your account), so committing it is fine for most cases. The docs cover the security model.

Step 3: Use the include on any page

Now drop the form anywhere you want it. In src/contact.njk:

---
layout: base.njk
title: Contact
permalink: /contact/
---

<h1>Get in touch</h1>
<p>I usually reply within 24 hours.</p>

{% include "contact-form.njk" %}

That's it. One line. Want the same form in the footer of every page? Add the include to _includes/base.njk or whatever your layout file is and every page in the site rebuilds with a contact form in the footer on the next build. Want a different form on the careers page? Make _includes/careers-form.njk and use a different access key (or the same key — splitforms can route by hidden form_id field and you'll get separate stats in the dashboard).

If your site uses Markdown for content pages, you can still include the form inside Markdown by enabling markdownTemplateEngine: "njk" in .eleventy.js. Then any .md file can call {% include "contact-form.njk" %} inline and Eleventy will process the include before handing the result to markdown-it. This is the trick that lets you ship a long-form essay with a contact CTA at the bottom without leaving the Markdown file.

Parameterised includes are also worth knowing. Nunjucks supports the {% include ... with %} pattern (via macros), so you can pass a custom subject or success URL per page: {% set subject = "About the pricing page" %}{% include "contact-form.njk" %}. The include reads {{ subject }} from the parent scope. This is how you keep one template that adapts to context — instead of forking it five times.

Theming with PostCSS or Sass

Eleventy doesn't ship a CSS pipeline — you bolt one on. The two common setups:

PostCSS via parallel npm script

// package.json
{
  "scripts": {
    "css": "postcss src/css/main.css -o dist/css/main.css --watch",
    "html": "eleventy --serve",
    "dev": "concurrently \"npm:css\" \"npm:html\""
  }
}

Then in src/css/main.css:

.contact-form {
  display: grid;
  gap: 14px;
  max-width: 520px;
}

.contact-form label {
  display: grid;
  gap: 6px;
  font-size: 14px;
  font-weight: 600;
}

.contact-form input,
.contact-form textarea {
  padding: 10px 12px;
  border: 1px solid #d4d4d4;
  border-radius: 8px;
  font: inherit;
}

.contact-form button {
  padding: 12px 18px;
  background: #111;
  color: #fff;
  border: 0;
  border-radius: 999px;
  font-weight: 700;
  cursor: pointer;
}

Sass

Same idea, swap postcss for sass: sass src/scss/main.scss dist/css/main.css --watch. If you prefer a plugin-based approach, @11ty/eleventy-plugin-postcss wires it into the build automatically without a parallel script.

The form HTML is plain HTML, so it works with Tailwind utilities (class="border rounded px-3 py-2") or any framework you already use.

Validation and spam protection

Browser-native validation handles 90% of cases. The required, type="email", minlength, maxlength, and pattern attributes are free, accessible, and don't require a single line of JavaScript. Use them first and reach for JS only when you need something the browser can't express:

<input
  type="email"
  name="email"
  required
  pattern="[^@]+@[^@]+\.[^@]+"
  title="Enter a valid email address"
/>

<textarea
  name="message"
  required
  minlength="20"
  maxlength="2000"
></textarea>

Server-side validation is the safety net — bots and broken browsers bypass HTML5 attributes. Configure required fields in the splitforms dashboard and submissions missing them get rejected with a 422 before the email is sent. That means you can't accidentally ship a form that lets people submit blank messages. If you want custom client-side checks (e.g., domain-blocking free-email providers, requiring a phone field for paid leads), intercept the submit event and call fetch() manually:

document.querySelector('.contact-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const form = e.currentTarget;
  const data = new FormData(form);

  const res = await fetch(form.action, {
    method: 'POST',
    body: data,
    headers: { Accept: 'application/json' }
  });

  if (res.ok) {
    form.replaceWith(Object.assign(document.createElement('p'), {
      textContent: 'Thanks — I\'ll reply within 24 hours.'
    }));
  } else {
    alert('Something went wrong. Try again.');
  }
});

Drop that into src/js/contact.js and reference it from your layout. Plain JS, no framework, no bundler required.

Spam protection without breaking accessibility

The honeypot field in Step 2 is the foundation. It catches automated form-fillers that submit every visible-and-invisible field they encounter. splitforms also runs AI spam classification on the free tier — it scores submissions by intent and quarantines low-quality ones automatically, so the email that lands in your inbox is much more likely to be a real human. Most 11ty sites never need reCAPTCHA, which is good because reCAPTCHA hurts conversion (Google measured around 4–6% drop in legitimate submissions when reCAPTCHA v2 is added, and the v3 invisible mode penalises privacy-focused browsers).

If you want belt-and-suspenders, add a time-trap field: a hidden input that JS fills with a timestamp on page load, then splitforms rejects submissions where the difference between page load and submit is under 2 seconds (humans take longer to type a message than that — bots don't). The honeypot vs reCAPTCHA tradeoff has more depth at honeypot vs reCAPTCHA, with measured pass-through rates for both approaches across a few thousand real submissions.

Build and deploy: Netlify or Cloudflare Pages

Eleventy builds to dist/ (or _site/). Both hosts pick it up automatically.

Netlify Pages

  1. Push your repo to GitHub.
  2. Netlify → Add new siteImport from Git.
  3. Build command: npx @11ty/eleventy (or npm run build if you have a script).
  4. Publish directory: dist (or _site).
  5. Deploy.

You do NOT need to enable Netlify Forms. In fact, leave it off — Netlify intercepts forms with a data-netlify="true" attribute, and our form doesn't have one, so it's ignored and posts directly to splitforms.

Cloudflare Pages

  1. Cloudflare Dashboard → Workers & PagesCreatePages.
  2. Connect your GitHub repo.
  3. Build command: npx @11ty/eleventy.
  4. Build output directory: dist.
  5. Deploy.

Cloudflare Pages has no built-in form handler at all, which is why splitforms is the path of least resistance there. The free 1,000 submissions/month carries the same Eleventy site whether it's on Cloudflare, Netlify, or GitHub Pages — the backend is host-agnostic. If you ever want to move hosts later, the form keeps working without a single code change because the action URL is pointing at splitforms, not at a host-specific endpoint.

One Eleventy-specific gotcha: if you have a .gitignore that excludes dist/ (you should), Netlify and Cloudflare won't see your built files in git — they run the build command on their server and use the output directory directly. Don't commit dist/. Don't check in node_modules. Make sure package.json lists every dependency Eleventy needs (any plugin, any PostCSS preset) — the deploy server installs from package-lock.json with a clean cache.

Test the form before you ship

Run npx @11ty/eleventy --serve locally. Browsersync (or Eleventy's newer dev server) hot-reloads as you edit. Open the contact page, fill in the form, submit. You should see:

  1. The browser navigates to your redirect URL (e.g., /thanks/).
  2. An email lands in the inbox associated with your access key within 5 seconds.
  3. The submission appears in splitforms.com/dashboard/submissions.

If you don't see the email, check spam first, then check the dashboard. If the dashboard has the submission but no email arrived, your SMTP configuration is the issue — the contact form not working guide walks through SPF/DKIM diagnosis. Most first-time issues are one of three things: a typo in the access key, the email rule was filtered into a Promotions folder, or Allowed Domains was set to a production URL that doesn't match your local localhost:8080.

Run one more test from a private/incognito window without your dev session cookies. This catches issues where you accidentally relied on a browser autofill that won't exist for a real visitor. Then push to staging (a preview deploy on Netlify or Cloudflare) and submit one more — those preview URLs run on a different subdomain, so they shake out any domain-restriction misconfiguration before the production deploy.

Troubleshooting common 11ty form issues

Nunjucks tags showing up literally in the HTML

You wrote {% include "contact-form.njk" %} in a Markdown file but the output still shows the literal tag. Fix: set markdownTemplateEngine: "njk" in .eleventy.js. Eleventy by default does NOT process template tags inside Markdown unless you tell it to.

Liquid vs Nunjucks confusion

If your project started with the default Eleventy starter, it's probably Liquid for Markdown and Nunjucks for layouts. The include syntax differs:

{# Nunjucks #}
{% include "contact-form.njk" %}

{# Liquid #}
{% include 'contact-form.liquid' %}

Pick one engine for includes and stick with it. The form HTML inside the include is identical either way.

Form HTML breaks when Markdown escapes it

If you paste the form HTML directly into a .md file, Eleventy's Markdown renderer (markdown-it) may wrap it in <p> tags or escape attributes. Two fixes: (1) put the form in an include and call it from Markdown, which bypasses the issue, or (2) wrap the form in a <div> block — markdown-it treats top-level block elements as raw HTML.

Browsersync not reloading on include changes

By default Eleventy watches all template files. If a change to _includes/contact-form.njk doesn't trigger a rebuild, add an explicit watch target in .eleventy.js:

module.exports = function (eleventyConfig) {
  eleventyConfig.addWatchTarget("./_includes/");
  eleventyConfig.setBrowserSyncConfig({
    files: "./dist/css/**/*.css"
  });
};

Also make sure you're running eleventy --serve, not eleventy alone, otherwise there's no dev server at all.

CORS errors in the console

splitforms allows cross-origin POSTs from any domain by default. If you see CORS errors, you probably enabled Allowed Domains in the dashboard security settings and forgot to add localhost for testing or your production domain on deploy. Re-check the allow-list or disable the restriction during dev.

401 Unauthorized on submit

The access key has a leading/trailing space, or you pasted the wrong key, or the key is restricted to a domain that doesn't match your current host. Re-copy the key from the dashboard, paste with care, and confirm Allowed Domains includes your current host.

Where to go next

FAQ

Can Eleventy handle a contact form natively?

No. Eleventy is a static site generator — it compiles your Markdown and Nunjucks templates to static HTML files in `dist/` (or whatever output dir you've set). There's no server, no PHP, no Node runtime in production. To process a form submission you either need a serverless function (Netlify Functions, Cloudflare Workers) or an external form backend like splitforms that accepts the POST and sends the email for you. The external backend is simpler — no function code to maintain, no cold starts, no secrets in your repo.

Why use a Nunjucks include instead of pasting the form HTML on every page?

Duplication. If you paste the form on five pages and then update the wording or add a field, you have to edit five files. With `{% include "contact-form.njk" %}` you change one file and every page rebuilds with the new version on next `eleventy --serve`. It also keeps your page templates readable — the include is one line instead of fifteen.

What if my site uses Liquid templates instead of Nunjucks?

Eleventy supports Liquid, Nunjucks, Handlebars, EJS, and a few others. The include syntax differs: Nunjucks uses `{% include "contact-form.njk" %}`, Liquid uses `{% include 'contact-form.liquid' %}` (single quotes, no file extension required if `_includes` is set). The form HTML itself is identical — both engines just pass through raw HTML. Set your default template engine in `.eleventy.js` with `setTemplateFormats` and `htmlTemplateEngine`.

Will the form work on Netlify Pages and Cloudflare Pages?

Yes. Both are pure static hosts for Eleventy output and have no role in form processing — the form POSTs directly to `splitforms.com/api/submit` from the browser, the host never sees it. Netlify Forms is a separate Netlify feature you don't need to enable. Cloudflare Pages doesn't have a built-in form handler at all, which is why most 11ty devs on Cloudflare end up using a backend like splitforms.

How do I theme the form with PostCSS or Sass?

Eleventy doesn't bundle a CSS pipeline — you add one yourself. Most setups use `@11ty/eleventy-plugin-postcss` or a parallel `npm script` that runs PostCSS/Sass via `concurrently` alongside `eleventy --serve`. Style your form by selector: `.contact-form input`, `.contact-form button`, etc. The form HTML is just HTML, so any CSS framework (Tailwind, Bulma, plain CSS variables) works without modification.

Does browsersync hot-reload pick up changes to my include?

Yes. Eleventy's built-in dev server (or older `--serve` mode using Browsersync) watches `_includes/` by default. Save `_includes/contact-form.njk` and every page that includes it re-renders. If the reload isn't firing, check that your `eleventyConfig.addWatchTarget` includes the file path and that you're not running Eleventy with `--quiet` swallowing the rebuild log.

How do I prevent spam without reCAPTCHA?

A honeypot field. Add `<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />` to the Nunjucks template — humans never see or check it, bots fill every field they can find. splitforms drops any submission where `botcheck` is true. AI spam classification runs on top of that on the free tier. Most 11ty sites never need a visible captcha. See the deep dive at /blog/honeypot-vs-recaptcha for the math on false positives.

Can I show a thank-you page after submit?

Two ways. Add `<input type="hidden" name="redirect" value="https://yoursite.com/thanks/" />` to redirect to a static thank-you page you've built in Eleventy. Or omit the redirect and intercept the form with a small JS handler that calls `fetch()` and swaps the form HTML for a success message. The redirect approach is simpler and survives JavaScript being disabled.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open