Guides

Eleventy (11ty) contact form that sends email

Eleventy outputs plain HTML, so a contact form only needs an action URL. Keep the key in global data, render the form from an include, and deploy wherever you like.

Last updated November 2, 2026

1Create a FormSMTP project

Sign up free and add a project. Pick your email provider so the SMTP host and port fill in, enter your login, and click Test connection. Not sure which provider? Your existing mailbox works: see the Gmail or Zoho guides.

2Lock the project to your site

In Settings → Security, set Allowed websites to https://yoursite.com and Allowed recipients to your own address. Now the key in your page can only email you, only from your site, and only up to your hourly limit.

Testing with npx @11ty/eleventy --serve? Also add http://localhost:8080 (or whichever port Eleventy prints) to Allowed websites while you work.

3Expose the key as global data

Files in _data become global data, named after the file. A JavaScript data file can read environment variables at build time, so the key stays out of your templates and your repository:

js
// _data/formsmtp.js
export default function () {
  const key = process.env.FORMSMTP_FORM_KEY;
  if (!key) console.warn("[formsmtp] FORMSMTP_FORM_KEY is not set");
  return {
    endpoint: "https://app.formsmtp.com/api/v1/form/" + key,
    subject: "New message from my Eleventy site",
  };
}

This is the ESM syntax used by Eleventy 3 projects with "type": "module". On a CommonJS project, write module.exports = function () { … } instead. Run the dev server with the key set:

bash
FORMSMTP_FORM_KEY=fk_your_key npx @11ty/eleventy --serve

On Netlify, Vercel or Cloudflare Pages, add FORMSMTP_FORM_KEY in the project’s environment variables instead.

4Create the include

html
{# _includes/contact-form.njk #}
<form class="contact-form" action="{{ formsmtp.endpoint }}" method="POST" data-contact>
  <label>Name <input name="name" autocomplete="name" required></label>
  <label>Email <input name="email" type="email" autocomplete="email" required></label>
  <label>Message <textarea name="message" rows="5" required></textarea></label>

  <input type="hidden" name="_subject" value="{{ subject or formsmtp.subject }}">
  <input type="hidden" name="_next" value="/thanks/">
  <input type="hidden" name="_t" value="">
  <input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">

  <button type="submit">Send message</button>
  <p role="status"></p>
</form>

Nunjucks includes see the variables of the page that includes them, so a page can set subject to change the subject line. Prefer Liquid? Save the file as contact-form.liquid and write {{ subject | default: formsmtp.subject }}; the rest is the same.

5Add the contact and thank-you pages

markdown
---
# contact.md
layout: base.njk
title: Contact
templateEngineOverride: njk,md
---

Questions, quotes or just hello.

{% set subject = "New message from the contact page" %}
{% include "contact-form.njk" %}

Eleventy processes Markdown files with Liquid by default, which doesn’t understand Nunjucks’ or. templateEngineOverride: njk,md runs Nunjucks first, then Markdown. In a .njk page you don’t need it.

markdown
---
# thanks.md
layout: base.njk
title: Thanks!
permalink: /thanks/
eleventyExcludeFromCollections: true
---

Your message is on its way. We'll reply within a day or two.

eleventyExcludeFromCollections keeps the thank-you page out of collections, so it won’t appear in your sitemap or post lists. If your site uses a pathPrefix, put the prefix in _next too.

6Optional: an inline success message

Add a small script and copy it to the output with passthrough copy. It also fills in _t, the page-load time, which lets FormSMTP drop bots that submit within two seconds.

js
// js/contact-form.js
for (const form of document.querySelectorAll("form[data-contact]")) {
  const status = form.querySelector("[role=status]");
  form.elements._t.value = Date.now();

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    status.textContent = "Sending…";
    const res = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new URLSearchParams(new FormData(form)),
    });
    const data = await res.json();
    status.textContent = data.success ? "Thanks! Your message was sent." : data.error;
    if (data.success) form.reset();
  });
}
js
// eleventy.config.js
export default function (eleventyConfig) {
  eleventyConfig.addPassthroughCopy("js");
}

Load it from your layout with <script src="/js/contact-form.js" defer></script>. Without JavaScript, the form still posts and lands on /thanks/.

Deploy anywhere

Eleventy builds to _site by default, and the form needs nothing from the host, so any static host works: Netlify, Vercel, Cloudflare Pages, GitHub Pages through Actions, or an S3 bucket. Set FORMSMTP_FORM_KEY wherever the build runs, add your live domain to Allowed websites, and send a test message from production.

Troubleshooting

  • The form posts to …/form/undefined: the environment variable wasn’t set when Eleventy built the page. Environment variables are only read in JavaScript files at build time, so set it in your host’s build settings and rebuild.
  • The page shows a Liquid error near or: a Markdown page ran the Nunjucks include through Liquid. Add templateEngineOverride: njk,md to its front matter.
  • “This form isn’t allowed on http://localhost:8080”: add the dev server’s origin to Allowed websites, or test on the deployed site.
  • /js/contact-form.js returns 404: the passthrough copy is missing, or the config file isn’t named eleventy.config.js.

FAQ

Nunjucks or Liquid?

Either works. This guide uses Nunjucks because its includes see page variables directly. With Liquid, save the include as .liquid and use the default filter for the subject.

Does it work with Eleventy 2?

Yes. Write the data file with module.exports instead of export default and keep the config in CommonJS. The form itself is plain HTML, so it doesn’t depend on the Eleventy version.

Is it safe to put the key in my front-end code?

Yes, the form key is made for that. It starts with fk_, only works on the form endpoint and always delivers to your own inbox, with per-visitor limits and a spam filter on top. A copied form key can’t email anyone else. Your API key (fs_) can, so keep it in server code.

Why did my message land in spam?

Usually the From address and the SMTP account don’t match, or the domain has no SPF/DKIM records. Send from the address you log in with, and check the Deliverability panel in FormSMTP’s Settings.

More guides

Get a Founding Pass

10,000 emails a month, free for your first year. Pass No. 0008 is next: 1,993 of 2,000 left, no card.