Guides

Jekyll contact form that sends email (no plugins)

Jekyll turns Liquid templates into static HTML, so a form only needs somewhere to post. Put the key in _config.yml, write one include, and use it in any page or post.

Last updated November 2, 2026

Just want to paste a form into a plain HTML site on GitHub Pages? The GitHub Pages contact form guide covers that. This guide is for Jekyll’s templating: one reusable include, settings in your config, and links that survive a baseurl.

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://yourname.github.io 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.

Allowed websites matches the origin only, never the path. https://yourname.github.io covers every project site under it, such as /my-blog/. With a custom domain, add that too. While you test with jekyll serve, also add http://127.0.0.1:4000.

3Add the key to _config.yml

yaml
# _config.yml
formsmtp:
  key: "YOUR_FORM_KEY"
  subject: "New message from my Jekyll site"

Jekyll reads _config.yml once at startup, so restart jekyll serve after you change it. Everything under formsmtp is available in templates as site.formsmtp.

4Write the include

html
<!-- _includes/contact-form.html -->
{% assign subject = include.subject | default: site.formsmtp.subject %}
<form class="contact-form" action="https://app.formsmtp.com/api/v1/form/{{ site.formsmtp.key }}" method="POST">
  <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="page" value="{{ page.url }}">
  <input type="hidden" name="_subject" value="{{ subject | escape }}">
  <input type="hidden" name="_next" value="{{ '/thanks/' | relative_url }}">
  <input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">

  <button type="submit">{{ include.button | default: "Send message" }}</button>
</form>

Values passed to an include arrive as include.something, and Liquid’s default filter falls back to your config when a page doesn’t pass one. The hidden page field shows up in the email, so you know which page the message came from.

5Use it in any page or post

Jekyll runs Liquid inside Markdown, so you don’t need a shortcode. Drop the include straight into the content:

markdown
---
layout: page
title: Contact
permalink: /contact/
---

Questions, quotes or just hello. We reply within a day or two.

{% include contact-form.html %}

Need a second form with its own subject line, say on a pricing page? Pass parameters:

liquid
{% include contact-form.html subject="New quote request" button="Get a quote" %}

Each form emails you with its own subject, so you can filter them in your inbox.

6Add the thank-you page

markdown
---
# thanks.md
layout: page
title: Thanks!
permalink: /thanks/
sitemap: false
---

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

[Back to the homepage]({{ '/' | relative_url }})

sitemap: false keeps the page out of sitemap.xml if you use the jekyll-sitemap plugin. Build, push, and submit a test message: it lands in your inbox, sent from your own address, and shows up under Logs.

Why relative_url matters

A GitHub Pages project site lives under a path, such as https://yourname.github.io/my-blog/, and Jekyll knows it as baseurl: /my-blog. FormSMTP resolves _next against your site’s origin and only redirects back to that site. A hard-coded /thanks/ would send visitors to yourname.github.io/thanks/, which doesn’t exist. relative_url adds the baseurl for you, and does nothing on a site at the root of its domain.

Optional: stay on the page

To show a “Thanks!” message without leaving the page, add a small script at the end of the include. Without JavaScript, the form still posts and redirects as before.

html
<p class="contact-status" role="status"></p>
<script>
  // The include can appear more than once on a page: bind each form only once.
  document.querySelectorAll(".contact-form:not([data-bound])").forEach((form) => {
    form.dataset.bound = "";
    form.addEventListener("submit", async (e) => {
      e.preventDefault();
      const status = form.nextElementSibling;
      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();
    });
  });
</script>

With the Accept: application/json header, FormSMTP replies with JSON instead of redirecting, and _next is ignored.

GitHub Pages or anywhere else

Includes, default and relative_url are all built into Jekyll, so this needs no gems and works with GitHub Pages’ own build. The same site builds unchanged with GitHub Actions, Netlify or Cloudflare Pages: the browser posts to FormSMTP, so there’s no server code to deploy.

Troubleshooting

  • The form posts to …/form/ with no key: site.formsmtp.key is empty. Check the indentation in _config.yml and restart jekyll serve.
  • “This form isn’t allowed on http://127.0.0.1:4000”: the page’s origin isn’t in Allowed websites. Add it while you test, or test on the live site.
  • The thank-you page is a 404: _next was written without relative_url on a project site, or thanks.md has no permalink.
  • “File uploads are not supported”: remove enctype="multipart/form-data" from the form. The form endpoint takes text fields only.

FAQ

Does this need a Jekyll plugin?

No. Includes, the default filter and relative_url are part of Jekyll itself, so it works with the plugins GitHub Pages allows and with any other Jekyll build.

Can I use a different key while developing?

Yes. Put the development key in a second file such as _config.dev.yml and run jekyll serve --config _config.yml,_config.dev.yml. Later files override earlier ones.

Will the key be public in my repository?

The form key will be, like anything in front-end code. That’s what it’s for: it only emails you, and you can lock it to your site under Allowed websites. Never commit the API key (fs_).

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.