← All guides
Jekyll

Jekyll contact form without a server (works on GitHub Pages)

Add a contact form to a Jekyll site with one include and two lines in _config.yml. Works on GitHub Pages without plugins, submissions stored in Frankfurt.

Jekyll builds static HTML, and GitHub Pages only serves files. Nothing on the host can receive the POST when someone clicks "Send", and GitHub Pages runs no server code. The usual fixes are moving to a host with its own form feature, or writing a serverless function somewhere else plus an email provider.

With Formlane, the form posts straight from the browser to https://smallwerk.com/formlane/api/submit. Formlane stores the message and emails it to the form's notification address, with reply-to set to the sender. It works on any host that serves your _site folder: GitHub Pages, Cloudflare Pages, Netlify, Vercel or your own server.

Quick start

  1. Sign in to Formlane, create a form and copy its access key (fl_…).
  2. Add url and formlane_key to _config.yml.
  3. Create _includes/contact-form.html (below) and add {% include contact-form.html %} to your contact page.
  4. Create thanks.md, restart bundle exec jekyll serve, send a test message and check your inbox and the Formlane dashboard.
  5. In the form settings, restrict the form to your production domain, so forms on other websites can't post to it.

Code

_config.yml
title: Example
url: "https://example.com"   # your site's address, no trailing slash
baseurl: ""                  # "/repo-name" for a GitHub Pages project site
formlane_key: "fl_YOUR_ACCESS_KEY"

The key is public by design: it only allows sending submissions to this one form, so it is fine in your repository and in the built HTML. Every key in _config.yml is available in templates as site.<key>. Jekyll reads _config.yml only once at start, so restart jekyll serve after you change it.

_includes/contact-form.html
<form action="https://smallwerk.com/formlane/api/submit" method="POST" accept-charset="UTF-8">
  <input type="hidden" name="access_key" value="{{ site.formlane_key }}">
  <input type="hidden" name="subject" value="New message from {{ site.title }}">
  <input type="hidden" name="redirect" value="{{ '/thanks/' | absolute_url }}">
  <!-- Honeypot: hidden from people, leave it unchecked -->
  <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off">

  <label for="cf-name">Name</label>
  <input id="cf-name" type="text" name="name" autocomplete="name" required>

  <label for="cf-email">Email</label>
  <input id="cf-email" type="email" name="email" autocomplete="email" required>

  <label for="cf-message">Message</label>
  <textarea id="cf-message" name="message" rows="5" required></textarea>

  <button type="submit">Send</button>
</form>
contact.md
---
layout: default
title: Contact
permalink: /contact/
---

Questions? Write us.

{% include contact-form.html %}
thanks.md
---
layout: default
title: Thanks
permalink: /thanks/
---

Thanks for your message. We'll get back to you soon.

Use your theme's layout name instead of default if it is different. Two Jekyll details matter here: write the include without quotes: {% include contact-form.html %}. Tutorials for Eleventy use {% include "contact-form.html" %}, and Jekyll stops the build with "Invalid syntax for include tag". And don't indent the include line in a Markdown file. Four spaces turn it into a code block, and the form shows up as text.

The thank-you page

After a successful submission the redirect field sends visitors to /thanks/. It has to be an absolute http(s) URL, which is why the include uses Jekyll's absolute_url filter: it joins url, baseurl and the path. Without a redirect, Formlane uses the default redirect from your form settings, or its own hosted thank-you page.

During jekyll serve, Jekyll sets url to your local address, so the redirect goes to your local thank-you page while you test. On a GitHub Pages project site (https://username.github.io/repo-name/), set baseurl: "/repo-name", and the redirect becomes https://username.github.io/repo-name/thanks/.

Optional: stay on the page

If you'd rather show a message without a page change, send the form with fetch(). With Accept: application/json, Formlane answers {"success": true, "message": "Form submitted successfully", "id": "…"} or {"success": false, "error": "limit_reached", "message": "…"} with a 4xx/5xx status. message is always English, so the snippet shows its own text per error code. Append it to _includes/contact-form.html:

_includes/contact-form.html (append)
<script>
  const MESSAGES = {
    limit_reached: "This form can't take more messages this month. Please email us directly.",
    rate_limited: "Too many messages in a short time. Please try again in a few minutes.",
  };
  const form = document.querySelector('form[action*="formlane"]');
  if (form) form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const res = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const json = await res.json().catch(() => ({}));
    // Spam caught by the honeypot or the content rules also gets success: true.
    form.outerHTML = json.success
      ? "<p>Thanks! We'll get back to you.</p>"
      : "<p>" + (MESSAGES[json.error] || "Sorry, that didn't work. Please try again later.") + "</p>";
  });
</script>

Without JavaScript the plain form still works, so the script is a progressive enhancement, not a requirement.

Spam protection without a captcha

  • Honeypot: the hidden botcheck checkbox. If a bot ticks it, Formlane returns a normal success response and stores nothing.
  • Rate limit: 10 submissions per 10 minutes per visitor and form.
  • Content rules: messages with 4 or more links, link markup or typical spam phrases go to a separate spam tab. Spam doesn't count toward your monthly limit.
  • Allowed domains: limit the form to your own domain in its settings.

Limits worth knowing

  • Up to 64 KB per submission, 50 fields and 10,000 characters per field.
  • Content types: application/x-www-form-urlencoded, multipart/form-data (text fields only) and application/json.
  • File uploads are not supported yet. If your form needs attachments, use one of the alternatives below.

Alternatives, honestly compared

OptionGood fit whenTrade-off
FormlaneJekyll on GitHub Pages or any static host; you want email alerts and a dashboard without running codeNo file uploads yet; on the Free plan, notification emails and the hosted thank-you page carry a "Powered by Formlane" link
Netlify FormsYour Jekyll site is deployed on Netlify anywayOnly works on Netlify hosting, not on GitHub Pages; usage is billed in Netlify's credit plans
FormspreeYou want the best-known form backend and its integrationsFree plan: 50 submissions a month (plans page, checked 26.10.2026)
Web3FormsYou need many forms on the free tierFree plan: 250 submissions a month (checked 26.10.2026)
Your own serverless function (Netlify/Vercel/Cloudflare)You want full control over storage and emailGitHub Pages can't run it, so the function lives on a second host; you maintain it, an email provider account and spam protection yourself

Where the data is stored

Stored in Frankfurt, in a Supabase database in the eu-central-1 region. The app runs on Vercel: our server functions run in Frankfurt (fra1); static files and edge routing are served by Vercel's global network. Notification emails are sent through Resend, a US company. All providers are listed in the privacy policy. Formlane is run by Mindcaptain Services OÜ in Tallinn, Estonia. A data processing agreement is included in the Terms (Art. 28 GDPR, smallwerk.com/dpa), and IP hashes are deleted after 30 days. Add the contact form to your own privacy policy, and delete old submissions in the dashboard when you no longer need them.

Pricing

Free: 250 submissions a month and up to 5 forms. Starter: $8/month for 2,500 submissions. Pro: $19/month for 25,000 submissions. Prices are in USD incl. VAT where applicable (also available in EUR). A read-only REST API and MCP server are included on every plan, including Free.

FAQ

Can Jekyll send email from a contact form?

Not on its own: Jekyll only builds static files, and GitHub Pages runs no server code. A form backend receives the POST and sends the email for you.

Does this work on GitHub Pages without plugins?

Yes. The include only uses Liquid that Jekyll has built in (site.* variables and the absolute_url filter). Nothing has to be added to your Gemfile or the GitHub Pages plugin list.

Why does my build fail with "Invalid syntax for include tag"?

The file name is in quotes. In Jekyll, write {% include contact-form.html %} without quotes.

Is it safe to put the access key in _config.yml?

Yes. The Formlane access key is public by design and only allows sending submissions to that one form. Restrict the form to your domain in its settings so other sites can't use it.

What happens when I reach the monthly limit?

New submissions are rejected with the error limit_reached until the next calendar month or an upgrade. Stored submissions stay.

Get your access key

Free for 250 submissions a month. Submissions stored in Frankfurt.

Get your access key