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

How to Add a Working Contact Form to Jekyll Sites 2026

Add a real contact form to your Jekyll blog or GitHub Pages site in 2026 — no Ruby plugin, no backend, custom layout, and email delivery in 5 minutes.

✶ 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 Jekyll alone can't handle contact forms

Jekyll is a static site generator. It takes your Markdown, Liquid templates, and Sass files and spits out HTML, CSS, and JavaScript. There's no running server when a visitor lands on your site — it's files on a CDN. That's why Jekyll sites are fast, cheap, and basically un-hackable. It's also why a contact form can't work using Jekyll alone: someone has to be listening when the form POSTs, and on a static site nobody is.

The natural Ruby instinct is to reach for a plugin. There are a few floating around (jekyll-contact-form, jekyll-static-comments, etc.) but they all have the same problem: GitHub Pages only allows a small whitelist of plugins, and none of them handle form submissions. The official list includes jekyll-feed, jekyll-sitemap, jekyll-seo-tag, and a handful of others. Anything that needs to receive a POST is structurally outside what GitHub Pages can do.

You have two real options: self-host Jekyll on a VPS with a Ruby form processor (Sinatra, Rails, etc.) — which throws away the whole reason Jekyll is appealing — or send the POST to a hosted form backend that already runs the infrastructure. That's where splitforms fits in: you get 1,000 submissions/month free, no Ruby, no plugin, no build flags. If you want a fuller side-by-side of the Jekyll-friendly options, the best free form backends in 2026 writeup walks through each one.

The mental model: your Jekyll site is the front of house — HTML, CSS, branding, layout. splitforms is the back of house — receiving POSTs, storing them, sending email, fanning out to webhooks, filtering spam with AI. The browser is the messenger between them. None of that work runs on GitHub Pages; none of it requires you to keep a server warm. The form is just an HTML element pointing at a URL you don't own.

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

Open splitforms.com/login in a new tab. Enter your email. You'll get a 6-digit code — paste it back. The dashboard generates an access key automatically; copy it. That single string is everything splitforms needs to route submissions to your inbox.

While you're in the dashboard, set the notification email under Settings if it's different from your sign-up email, and add your production domain (e.g. blog.example.com) under Allowed Domains. The allowlist prevents anyone from stealing your access key and using it from their own site. Skip localhost for now — Jekyll's dev server runs at http://127.0.0.1:4000, and we'll handle local testing later.

Save the key somewhere you'll find it in 30 seconds. The next two steps both reference it.

Step 2: Store the key in _config.yml

The Jekyll-idiomatic way to hold this kind of value is in _config.yml so every template and include reads from one place. Open _config.yml at your project root and add:

# _config.yml

title: My Jekyll Blog
description: A static site that somehow has a working contact form

# splitforms config
splitforms_key: "YOUR_ACCESS_KEY_HERE"
splitforms_endpoint: "https://splitforms.com/api/submit"
splitforms_redirect: "/thanks/"

Three values: the key, the endpoint (rarely changes), and the post-submit redirect URL. Putting them in _config.yml means you can swap keys later without touching any HTML. If you rotate the key for security, you change one line and rebuild. Note that _config.yml changes don't hot-reload — you need to restart bundle exec jekyll serve for changes to take effect locally.

The access key is not a cryptographic secret. It ships in the rendered HTML of every page that contains the form. That's fine — domain locking on splitforms means even if someone copies the key off your page, they can't use it from their domain. So don't worry about committing _config.yml to a public repo on GitHub. Most Jekyll users do.

Step 3: Create _includes/contact-form.html

Jekyll's _includes directory is for reusable HTML partials. Anything in there can be pulled into a layout or page with {% include filename.html %}. Create a new file at _includes/contact-form.html with this content:

{%- comment -%}
  splitforms contact form
  Pulls config from _config.yml.
{%- endcomment -%}

<form
  class="contact-form"
  action="{{ site.splitforms_endpoint }}"
  method="POST"
  enctype="multipart/form-data"
>
  <input type="hidden" name="access_key" value="{{ site.splitforms_key }}" />
  <input type="hidden" name="redirect"   value="{{ site.url }}{{ site.splitforms_redirect }}" />
  <input type="hidden" name="subject"    value="New message from {{ site.title }}" />

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

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

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

  {%- comment -%} Honeypot — bots fill this in, humans never see it {%- endcomment -%}
  <input
    type="checkbox"
    name="botcheck"
    tabindex="-1"
    autocomplete="off"
    style="position:absolute;left:-9999px;"
  />

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

A few things worth pointing out. The action and key both come from site.* via Liquid, so this partial doesn't hardcode anything. The redirect input combines site.url with splitforms_redirect so the thank-you page works the same locally and in production. required and minlength give you free client-side validation in every modern browser without writing any JavaScript. And the honeypot at the bottom catches roughly 95% of spam — see honeypot vs reCAPTCHA for the trade-offs versus a real CAPTCHA.

Step 4: Include the partial in a page or layout

You have two reasonable patterns. The first: a dedicated contact page at contact.md in your project root.

---
# contact.md
layout: page
title: Contact
permalink: /contact/
---

Got something to say? Drop me a line.

{% include contact-form.html %}

That's it — the include drops the form into the rendered page. Jekyll runs the Liquid inside contact-form.html, fills in site.splitforms_key and friends, and outputs plain HTML. The page now lives at yoursite.com/contact/.

The second pattern: shove the form into a layout so it shows up site-wide. Open _layouts/default.html and add the include in the footer:

<!-- _layouts/default.html -->
<!DOCTYPE html>
<html lang="{{ page.lang | default: site.lang | default: 'en' }}">
  <head>{% include head.html %}</head>
  <body>
    {% include header.html %}
    <main>{{ content }}</main>
    <footer>
      <h3>Get in touch</h3>
      {% include contact-form.html %}
    </footer>
  </body>
</html>

Now every page that uses layout: default shows a contact form in the footer. If you're on the Minima theme or another gem-based theme, copy the theme's default.html from the gem into your local _layouts/ first — Jekyll uses local layouts in preference to the theme's. Run bundle info --path minima to find the gem path.

Step 5: Style the form with Jekyll's Sass pipeline

Jekyll has Sass built in — no plugin needed. Drop a partial in _sass/_contact-form.scss:

// _sass/_contact-form.scss

.contact-form {
  display: grid;
  gap: 12px;
  max-width: 540px;
  margin: 32px 0;
  font-family: system-ui, -apple-system, sans-serif;

  label {
    font-size: 13px;
    font-weight: 600;
    color: #1a1a1a;
  }

  input[type="text"],
  input[type="email"],
  textarea {
    width: 100%;
    padding: 10px 12px;
    border: 1px solid #d4d4d4;
    border-radius: 8px;
    font-size: 15px;

    &:focus {
      outline: 2px solid #2563eb;
      outline-offset: 1px;
      border-color: transparent;
    }
  }

  button {
    height: 44px;
    padding: 0 22px;
    background: #1a1a1a;
    color: #fafafa;
    border: 0;
    border-radius: 999px;
    font-weight: 700;
    cursor: pointer;

    &:hover { background: #000; }
  }
}

Then import it from your main stylesheet, typically assets/css/main.scss:

---
# assets/css/main.scss (front matter required so Jekyll compiles it)
---

@import "contact-form";
// any other partials...

The empty front matter at the top is mandatory — without it Jekyll skips Sass processing and treats the file as a static asset. With it, Jekyll compiles the whole tree at build time and outputs a single main.css. No build tooling, no npm, no Webpack. If you're using a theme and don't see assets/css/main.scss, create it — local files always win.

Step 6: Create the thank-you page

You set splitforms_redirect: "/thanks/" in _config.yml so after a successful submission the user lands on your thank-you page. Build it at thanks.md:

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

Your message landed. I usually reply within 24 hours.

[Back to the blog]({{ site.url }})

The sitemap: false front matter keeps it out of jekyll-sitemap's output so Google doesn't index a meaningless thanks URL. If you also have jekyll-seo-tag running, add noindex: true for an extra-belt-and-braces approach. The page is otherwise just markdown — anything you'd put on a regular Jekyll page works here.

If you'd rather not redirect at all — some folks prefer an inline "thanks" message that fades in without a page change — remove the redirect hidden input from the form. splitforms will then return a JSON response and you can intercept the submit with a tiny bit of vanilla JavaScript. The redirect approach is simpler though, and works without any JS, which matters if you care about a no-JS fallback for accessibility or scraper bots.

Step 7: Deploy to GitHub Pages

There are two ways to put a Jekyll site on GitHub Pages: build locally and push the output to the gh-pages branch, or push source to main and let GitHub build it for you. The second is simpler but limits you to the allowlisted plugin set. The first lets you use any plugin (jekyll-archives, jekyll-paginate-v2, etc.) but you handle the build.

For the second (GitHub builds it):

  1. Push your repo to GitHub.
  2. Settings → Pages → Source = "Deploy from a branch" → Branch = main → Folder = / (root).
  3. Wait 30–60 seconds. Your site is live at username.github.io/repo.

For the first (you build and push _site):

# Install the gh-pages workflow once
gem install jekyll
bundle install

# Build locally
JEKYLL_ENV=production bundle exec jekyll build

# Push the _site directory to the gh-pages branch
git subtree push --prefix _site origin gh-pages

Or, more commonly, use a GitHub Action — drop .github/workflows/jekyll.yml with the official Jekyll build action and let it push _site to gh-pages on every commit to main. This is the path you want if you use jekyll-archives, custom Ruby plugins, or anything outside GitHub's allowlist. The contact form works identically either way — splitforms doesn't care how the static HTML got there.

Once deployed, navigate to your live site and submit a test message. You should receive the notification email within a few seconds. If you also want the same form on a JavaScript-first stack later, the patterns map cleanly onto a Astro form backend or Next.js form backend setup.

Testing on localhost

Run bundle exec jekyll serve and open http://127.0.0.1:4000. If you submit the form right now, splitforms will reject it with 403 because localhost isn't in your Allowed Domains list. Three options:

  • Add localhost to allowed domains in the splitforms dashboard during development, then remove it before going live. Cleanest separation.
  • Disable Allowed Domains entirely while you develop and enable it once production is live. Easiest but slightly less secure.
  • Use a separate dev key with no domain restrictions, and a prod key locked to your real domain. Pro plan unlocks unlimited keys; on the free tier you can rotate one key. This is what I do.

Use the browser network tab to confirm the POST goes to splitforms.com/api/submit and returns 200. If you see CORS errors in the console, that almost always means a malformed action URL — check for typos like missing https or extra slashes.

One Jekyll-specific gotcha during local dev: changes to _config.yml require a server restart, but changes to _includes, _layouts, _sass, and content files all hot-reload. So if you tweak the form HTML and save, the rebuild is automatic. If you update the access key in _config.yml and the rendered HTML still shows the old key, that's why — kill jekyll serve with Ctrl-C and restart it. Roughly half the "my form isn't working" problems on Jekyll trace back to a stale config.

Troubleshooting

jekyll-archives plugin breaks the build on GitHub Pages

jekyll-archives isn't on GitHub Pages' allowlist. If you push source and let GitHub build, your archives pages won't generate and the build may fail silently. The fix is to switch to a GitHub Action that runs bundle exec jekyll build with the gem installed, then pushes _site to gh-pages. Once you do that, every plugin works.

baseurl is wrong and assets 404

If your site is served from username.github.io/repo rather than a custom domain, you need baseurl: "/repo" in _config.yml. Otherwise your form's redirect ({{ site.url }}{{ site.splitforms_redirect }}) will point at username.github.io/thanks/ instead of username.github.io/repo/thanks/. Always prefix internal URLs with {{ site.baseurl }} if you set one.

GitHub Pages build output isn't updating

Check Settings → Pages and look for the deployment status. If it says "Your site is published" but you don't see changes, it's usually one of: (1) the build is still running — wait 60 seconds, (2) your CDN/browser cache is stale — hard refresh, (3) the build failed silently because of an unsupported plugin — check the Actions tab for red Xs.

Cloudflare cache serving the old form

If your Jekyll site is behind Cloudflare, the new HTML can take hours to propagate because Cloudflare caches HTML by default for static sites. Go to Caching → Configuration → Purge Custom URL and paste the contact page URL. Or set a page rule to bypass cache on the contact page. The form works the moment the cache clears.

Submission returns 401 Unauthorized

The access key is wrong, has leading whitespace, or doesn't match the domain. Open the splitforms dashboard, copy the key fresh, paste it into _config.yml, restart jekyll serve, and confirm the rendered HTML contains the full key with no extra characters. Also check Allowed Domains.

Emails not arriving

Check spam, then check the splitforms dashboard — if the submission shows there but no email arrived, it's a deliverability issue, not a form issue. See contact form not working for SPF and DMARC fixes. The dashboard always has the data even if email fails.

Next steps

FAQ

Why can't I just use a Jekyll plugin for the contact form?

Because GitHub Pages — where 90% of Jekyll sites are hosted — only allows a small allowlist of Ruby plugins, and form-handling plugins aren't on it. Any plugin that needs to receive POSTs would need a running server, which GitHub Pages doesn't provide. You can self-host Jekyll on a VPS to use any plugin, but at that point you've thrown away the main reason to use Jekyll. A third-party form backend like splitforms is the correct architecture for a static-by-design generator.

Where do I put my splitforms access key in a Jekyll project?

Put it in `_config.yml` as a top-level value like `splitforms_key: "abc123..."`, then reference it in your include with `{{ site.splitforms_key }}`. This makes the key easy to update in one place. The access key isn't a secret in the cryptographic sense — it ships in your public HTML on every Jekyll site, that's fine because splitforms domain-locks each key. But keeping it in `_config.yml` instead of hard-coding it across multiple includes is just better Jekyll hygiene.

Will my form work on GitHub Pages without enabling anything special?

Yes. The form is plain HTML inside a static page — GitHub Pages serves it as-is, your visitor's browser POSTs directly to splitforms.com, and the response (redirect or thank-you message) comes back without GitHub Pages being involved beyond serving the original HTML. There's no build flag, no environment variable, no GitHub Action required. If your site already builds and deploys, the form works the moment the deploy is live.

How do I style the form using Jekyll's Sass pipeline?

Drop a partial like `_sass/_contact-form.scss` into the `_sass` directory and import it from your main stylesheet with `@import "contact-form";`. Jekyll's built-in Sass converter compiles the whole tree into one CSS file at build time. The contact-form partial can scope styles under `.contact-form` so they don't leak. This is the standard Jekyll-recommended pattern and doesn't require any plugin.

Can I use this with the jekyll-archives plugin or Minima theme?

Yes. The form is just an `{% include %}` call — it works inside any layout, including Minima's `default.html`, theme-specific layouts, and archive pages produced by `jekyll-archives`. If you use a gem-based theme, override its layout by copying it from the theme gem into your local `_layouts` directory, then add the include where you want the form. The include itself doesn't care about themes.

Why is my Cloudflare-fronted Jekyll site still showing the old form?

Cloudflare aggressively caches HTML for static sites. Even after `bundle exec jekyll build` and a push, Cloudflare may serve the old version for hours. Purge the page from the Cloudflare dashboard (Caching → Configuration → Purge Custom URL), or set a cache rule to bypass HTML, or just hard-refresh in an incognito window to confirm the new build is live on origin. If origin shows the new form but Cloudflare shows the old one, it's a cache issue, not a Jekyll issue.

What about file uploads from the contact form?

splitforms accepts `multipart/form-data` submissions so a Jekyll form with `<input type="file">` works as long as you set `enctype="multipart/form-data"` on the `<form>` tag. The uploaded file shows in the dashboard and the email notification includes a download link. Free-tier limit is 10MB per file as of 2026-05. For anything larger, post-process via webhook to S3 or R2 — webhooks are free on splitforms.

How do I add reCAPTCHA or hCaptcha to a Jekyll contact form?

You can, but the honeypot in this guide catches about 95% of spam without breaking accessibility or annoying users. splitforms also runs AI spam classification on every submission for free. If you still want a CAPTCHA, drop the hCaptcha script tag into your `_includes/contact-form.html`, add the widget div, and splitforms will pass the response token through. See /blog/honeypot-vs-recaptcha for the trade-offs before adding it.

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