Home β€Ί Blog β€Ί How to Add a Contact Form to Your Jekyll Site
Tutorial

How to Add a Contact Form to Your Jekyll Site

Add a working contact form to Jekyll without a backend β€” using Formspree, Netlify Forms, or Getform. Setup guides for all three with spam protection tips.

How to Add a Contact Form to Your Jekyll Site

Jekyll generates static HTML β€” there’s no server-side code to process form submissions. But you don’t need one. Three services handle form submissions for static sites, and all have free tiers that cover most personal and small business needs.


The Three Best Options

Β  Formspree Netlify Forms Getform
Free submissions 50/month 100/month 100/month
Setup difficulty Very easy Easy (Netlify only) Very easy
Spam protection reCAPTCHA, honeypot Honeypot, Akismet reCAPTCHA, honeypot
File uploads Paid Paid Free
Webhooks Paid Yes Yes
Works on any host Yes Netlify only Yes

Formspree is the simplest option β€” point your form’s action at a Formspree endpoint and submissions are emailed to you.

Setup

  1. Sign up at formspree.io (free)
  2. Click New Form, give it a name
  3. Copy your form endpoint URL (looks like https://formspree.io/f/xpzgkwlr)

HTML

<!-- contact.md or _pages/contact.md -->
---
layout: page
title: Contact
permalink: /contact/
---

<form action="https://formspree.io/f/YOUR_FORM_ID" method="POST" class="contact-form">
  <div class="form-group">
    <label for="name">Name <span aria-hidden="true">*</span></label>
    <input type="text" id="name" name="name" required autocomplete="name">
  </div>

  <div class="form-group">
    <label for="email">Email <span aria-hidden="true">*</span></label>
    <input type="email" id="email" name="email" required autocomplete="email">
  </div>

  <div class="form-group">
    <label for="subject">Subject</label>
    <input type="text" id="subject" name="subject">
  </div>

  <div class="form-group">
    <label for="message">Message <span aria-hidden="true">*</span></label>
    <textarea id="message" name="message" rows="6" required></textarea>
  </div>

  <!-- Honeypot spam protection -->
  <input type="text" name="_gotcha" style="display:none">

  <!-- Redirect after submission -->
  <input type="hidden" name="_next" value="https://yourdomain.com/thank-you/">

  <button type="submit" class="btn btn--primary">Send Message</button>
</form>

Create a Thank You Page

Create _pages/thank-you.md:

---
layout: page
title: Message Sent
permalink: /thank-you/
sitemap: false
---

Thanks for getting in touch β€” I'll reply within 1–2 business days.

AJAX Submission (No Page Redirect)

For a smoother experience, submit with JavaScript:

<form id="contact-form" action="https://formspree.io/f/YOUR_FORM_ID" method="POST">
  <!-- form fields as above -->
  <button type="submit" id="submit-btn">Send Message</button>
  <p id="form-status" style="display:none"></p>
</form>

<script>
  const form = document.getElementById('contact-form');
  const status = document.getElementById('form-status');
  const btn = document.getElementById('submit-btn');

  form.addEventListener('submit', async function(e) {
    e.preventDefault();
    btn.disabled = true;
    btn.textContent = 'Sending...';

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

      if (response.ok) {
        form.reset();
        status.textContent = "Thanks! I'll be in touch soon.";
        status.style.color = 'green';
        btn.textContent = 'Sent βœ“';
      } else {
        throw new Error('Server error');
      }
    } catch (err) {
      status.textContent = 'Something went wrong. Please try again.';
      status.style.color = 'red';
      btn.disabled = false;
      btn.textContent = 'Send Message';
    }
    status.style.display = 'block';
  });
</script>

Option 2: Netlify Forms (Best if Hosting on Netlify)

If your Jekyll site is hosted on Netlify, form handling is built in β€” no third-party service needed.

Setup

Add netlify attribute to your form tag. That’s it:

<form name="contact" method="POST" data-netlify="true" netlify-honeypot="bot-field">
  <input type="hidden" name="form-name" value="contact">
  
  <!-- Honeypot -->
  <div style="display:none">
    <label>Don't fill this out: <input name="bot-field"></label>
  </div>

  <div class="form-group">
    <label for="name">Name</label>
    <input type="text" id="name" name="name" required>
  </div>

  <div class="form-group">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required>
  </div>

  <div class="form-group">
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" required></textarea>
  </div>

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

Netlify detects the data-netlify="true" attribute at build time and registers the form automatically. Submissions appear in your Netlify dashboard under Forms.

Email Notifications

In Netlify Dashboard β†’ Forms β†’ your form β†’ Form notifications, add your email address. You’ll get an email for every submission.

Free Plan Limit

100 form submissions/month on Netlify’s free tier. Enough for most personal sites and small businesses.


Option 3: Getform

Getform is similar to Formspree with a generous free tier.

Setup

  1. Sign up at getform.io
  2. Create a form endpoint
  3. Copy the endpoint URL
<form action="https://getform.io/f/YOUR_ENDPOINT" method="POST">
  <input type="text" name="name" placeholder="Name" required>
  <input type="email" name="email" placeholder="Email" required>
  <textarea name="message" placeholder="Message" required></textarea>
  
  <!-- Honeypot -->
  <input type="hidden" name="_gotcha" style="display:none">
  
  <button type="submit">Send</button>
</form>

Styling the Contact Form

// _sass/components/_forms.scss

.contact-form {
  max-width: 600px;
}

.form-group {
  margin-bottom: 1.25rem;

  label {
    display: block;
    font-weight: 600;
    margin-bottom: 0.375rem;
    font-size: 0.9rem;
    color: var(--text-color);
  }

  input[type="text"],
  input[type="email"],
  textarea {
    width: 100%;
    padding: 0.625rem 0.875rem;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-md);
    background: var(--bg-color);
    color: var(--text-color);
    font-size: 1rem;
    font-family: inherit;
    transition: border-color 0.15s;

    &:focus {
      outline: none;
      border-color: var(--color-primary);
      box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
    }
  }

  textarea {
    resize: vertical;
    min-height: 140px;
  }
}

Spam Protection

All three services include basic spam protection. For extra security:

Honeypot field β€” A hidden field bots fill in but humans don’t:

<input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">

Check for it if processing on your end β€” any submission where _gotcha is filled is a bot.

reCAPTCHA v3 (Formspree Pro) β€” Invisible challenge, no checkbox needed. Better UX than v2.

Rate limiting β€” All three services rate-limit submissions by IP automatically.


Which to Choose

  • Formspree β€” best default choice, works on any host, cleanest setup
  • Netlify Forms β€” best if you’re already on Netlify, no third-party account needed
  • Getform β€” good Formspree alternative with file upload support on free tier

All three have free tiers that handle 50–100 submissions/month β€” more than enough for contact forms on most sites.


Browse Jekyll themes on JekyllHub β€” several themes include a pre-styled contact page ready to connect to your form service.


Building the form HTML

A well-structured contact form has more to it than just the fields. Here is a complete, accessible form ready to work with any of the three services above:


---
layout: page
title: Contact
description: Get in touch β€” I'll respond within 48 hours.
permalink: /contact/
---

<div class="contact-page">
  <div class="contact-intro">
    <h2>Get in Touch</h2>
    <p>Have a question about Jekyll themes, want to submit your own theme, or just want to say hello? Fill in the form below.</p>
  </div>

  <form class="contact-form" action="https://formspree.io/f/YOUR_FORM_ID" method="POST">
    <!-- Honeypot field β€” bots fill this, humans don't -->
    <input type="text" name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">

    <!-- Redirect after submission (optional β€” remove for AJAX approach) -->
    <input type="hidden" name="_next" value="{{ site.url }}/contact/thank-you/">

    <div class="form-group">
      <label for="name">Full name <span aria-hidden="true">*</span></label>
      <input
        type="text"
        id="name"
        name="name"
        required
        autocomplete="name"
        placeholder="Your name"
      >
    </div>

    <div class="form-group">
      <label for="email">Email address <span aria-hidden="true">*</span></label>
      <input
        type="email"
        id="email"
        name="email"
        required
        autocomplete="email"
        placeholder="you@example.com"
      >
    </div>

    <div class="form-group">
      <label for="subject">Subject</label>
      <select id="subject" name="subject">
        <option value="">Select a topic</option>
        <option value="Theme enquiry">Theme enquiry</option>
        <option value="Submit a theme">Submit a theme</option>
        <option value="Partnership">Partnership</option>
        <option value="Other">Other</option>
      </select>
    </div>

    <div class="form-group">
      <label for="message">Message <span aria-hidden="true">*</span></label>
      <textarea
        id="message"
        name="message"
        rows="6"
        required
        placeholder="How can we help?"
      ></textarea>
    </div>

    <button type="submit" class="btn btn-primary">Send Message</button>
  </form>
</div>

Create a thank-you page at _pages/contact-thank-you.md:

---
layout: page
title: Message Sent
permalink: /contact/thank-you/
---

Thanks for reaching out! We'll get back to you within 48 hours.

[Back to home](/){: .btn }

Styling the contact form

A clean, consistent form style that works with most Jekyll themes:

/* _sass/components/_contact-form.scss */

.contact-page {
  max-width: 680px;
  margin: 0 auto;
}

.contact-intro {
  margin-bottom: $space-8;

  h2 { margin-bottom: $space-3; }

  p {
    color: var(--text-muted);
    font-size: 1.05rem;
  }
}

.contact-form {
  .form-group {
    margin-bottom: $space-5;

    label {
      display: block;
      font-weight: 600;
      font-size: $font-size-sm;
      margin-bottom: $space-1;
      color: var(--text-primary);

      span { color: var(--color-danger, #dc2626); }
    }

    input[type="text"],
    input[type="email"],
    select,
    textarea {
      width: 100%;
      padding: 0.625rem 0.875rem;
      border: 1.5px solid var(--border-color);
      border-radius: $radius-md;
      background: var(--bg-primary);
      color: var(--text-primary);
      font-size: 1rem;
      font-family: inherit;
      line-height: 1.5;
      transition: border-color $transition-fast, box-shadow $transition-fast;
      -webkit-appearance: none;

      &::placeholder {
        color: var(--text-muted);
        opacity: 0.7;
      }

      &:focus {
        outline: none;
        border-color: var(--color-primary);
        box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
      }

      &:invalid:not(:placeholder-shown) {
        border-color: #dc2626;
      }
    }

    textarea {
      resize: vertical;
      min-height: 140px;
    }

    select {
      background-image: url("data:image/svg+xml,%3Csvg...");
      background-repeat: no-repeat;
      background-position: right 0.75rem center;
      padding-right: 2.5rem;
    }
  }

  .btn {
    width: 100%;
    padding: 0.75rem;
    font-size: 1rem;

    @media (min-width: 480px) {
      width: auto;
      padding: 0.75rem 2rem;
    }
  }
}

AJAX form submission with status feedback

The redirect approach works but feels dated. An AJAX submission lets users see a success message without leaving the page:

<form id="contact-form" class="contact-form" action="https://formspree.io/f/YOUR_FORM_ID" method="POST">
  <!-- form fields as above -->
  <button type="submit" id="submit-btn" class="btn btn-primary">
    <span id="btn-text">Send Message</span>
    <span id="btn-loading" style="display:none">Sending...</span>
  </button>

  <div id="form-feedback" class="form-feedback" style="display:none" role="alert" aria-live="polite">
  </div>
</form>

<script>
(function () {
  const form = document.getElementById('contact-form');
  if (!form) return;

  const btn = document.getElementById('submit-btn');
  const btnText = document.getElementById('btn-text');
  const btnLoading = document.getElementById('btn-loading');
  const feedback = document.getElementById('form-feedback');

  form.addEventListener('submit', async function (e) {
    e.preventDefault();

    // UI: loading state
    btn.disabled = true;
    btnText.style.display = 'none';
    btnLoading.style.display = 'inline';
    feedback.style.display = 'none';

    try {
      const response = await fetch(form.action, {
        method: 'POST',
        body: new FormData(form),
        headers: { 'Accept': 'application/json' }
      });

      if (response.ok) {
        form.reset();
        feedback.textContent = "Message sent β€” we'll reply within 48 hours.";
        feedback.className = 'form-feedback form-feedback--success';
        btn.textContent = 'Sent βœ“';
        btn.disabled = true;
      } else {
        const data = await response.json();
        throw new Error(data.errors?.[0]?.message || 'Submission failed');
      }
    } catch (err) {
      feedback.textContent = err.message.includes('fetch')
        ? 'Connection error. Please check your internet and try again.'
        : 'Something went wrong. Try emailing us directly.';
      feedback.className = 'form-feedback form-feedback--error';
      btn.disabled = false;
      btnText.style.display = 'inline';
      btnLoading.style.display = 'none';
    }

    feedback.style.display = 'block';
  });
})();
</script>

Add feedback styles:

.form-feedback {
  margin-top: $space-4;
  padding: $space-3 $space-4;
  border-radius: $radius-md;
  font-size: $font-size-sm;
  font-weight: 500;

  &--success {
    background: #f0fdf4;
    color: #166534;
    border: 1px solid #bbf7d0;
  }

  &--error {
    background: #fef2f2;
    color: #991b1b;
    border: 1px solid #fecaca;
  }
}

Accessibility checklist for contact forms

Forms are one of the most common accessibility failure points on websites. Before you ship your contact page, verify:

Every input has an associated <label> with a matching for/id pair β€” not a placeholder acting as a label. Placeholders disappear when the user starts typing and are not a substitute for visible labels.

Error messages are associated with their input via aria-describedby and appear near the relevant field, not only at the top of the form.

Required fields are indicated both visually (the asterisk) and programmatically (required attribute). Screen readers announce required on focus.

The submit button is a proper <button type="submit"> or <input type="submit">, not a styled <div> with a click handler.

The form gives focus to the first error field after a failed validation attempt, or to the success message after a successful submission β€” so keyboard and screen reader users know what happened.

Test the form using only the keyboard (Tab, Shift+Tab, Enter, Space) to verify every action is reachable and logical.


Setting up form notifications

Whichever service you use, configure email notifications properly. Formspree and Getform both email you on every submission by default. For Netlify Forms, add notifications in the dashboard under Forms β†’ your form β†’ Form notifications.

Beyond email, both Formspree and Getform support webhook delivery β€” useful for forwarding submissions to Slack, creating Trello cards, or triggering Zapier automations. Netlify Forms supports these too through Netlify Functions.

Browse Jekyll themes on JekyllHub β€” many include a styled contact page template that you can connect to your chosen form service in minutes.


Alternative: building a self-hosted form handler

The three services above cover most use cases with their free tiers. But if you need complete control β€” custom business logic, no submission limits, integration with your own database β€” a self-hosted serverless function is straightforward to build.

With Netlify Functions, create netlify/functions/contact.js:

const nodemailer = require('nodemailer');

exports.handler = async (event) => {
  if (event.httpMethod !== 'POST') {
    return { statusCode: 405, body: 'Method not allowed' };
  }

  const { name, email, message } = JSON.parse(event.body);

  // Basic validation
  if (!name || !email || !message) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'All fields required' })
    };
  }

  // Honeypot check
  if (event.body.includes('"_gotcha":"') &&
      JSON.parse(event.body)._gotcha !== '') {
    return { statusCode: 200, body: 'OK' };  // Silently accept bot submissions
  }

  const transporter = nodemailer.createTransporter({
    host: process.env.SMTP_HOST,
    port: 587,
    auth: {
      user: process.env.SMTP_USER,
      pass: process.env.SMTP_PASS
    }
  });

  await transporter.sendMail({
    from: `"${name}" <${process.env.SMTP_USER}>`,
    to: process.env.CONTACT_EMAIL,
    replyTo: email,
    subject: `Contact form: ${name}`,
    text: message,
    html: `<p><strong>From:</strong> ${name} (${email})</p><p>${message.replace(/\n/g, '<br>')}</p>`
  });

  return {
    statusCode: 200,
    body: JSON.stringify({ success: true })
  };
};

Point your form at the function:

<form action="/.netlify/functions/contact" method="POST">
  <!-- fields -->
</form>

This approach costs nothing (Netlify’s free tier includes 125,000 function requests per month) and gives you complete control over what happens with submissions.


Contact form rate limiting and spam prevention

Even with honeypot fields, contact forms attract spam as your site grows. A layered approach to spam prevention:

Layer 1: Honeypot field. Already covered β€” a hidden field that bots fill in. Catches the simplest bots.

Layer 2: Time-based check. Most bots submit forms instantly. Record the page load time and reject submissions that come in under 3 seconds:

const formLoadTime = Date.now();

form.addEventListener('submit', function(e) {
  const elapsed = Date.now() - formLoadTime;
  if (elapsed < 3000) {
    e.preventDefault();
    return;  // Silently reject β€” don't tell the bot it failed
  }
  // Normal submission handling
});

Layer 3: Cloudflare Turnstile (preferred over reCAPTCHA). Free, privacy-friendly, and invisible to most users. Replaces Google reCAPTCHA v3. Add the script and a widget:

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>

Verify the token server-side in your function before sending the email.

Layer 4: Rate limiting by IP. Cloudflare’s WAF or Netlify’s edge functions can limit form submissions per IP per hour. Configure this in your hosting platform’s settings rather than in your application code.


Tracking form conversions in analytics

If you are using the contact form as a business lead-generation tool, tracking conversions is important. With Google Analytics 4:

form.addEventListener('submit', function () {
  // Track conversion event
  if (typeof gtag !== 'undefined') {
    gtag('event', 'generate_lead', {
      event_category: 'Contact',
      event_label: 'Contact Form Submission'
    });
  }
});

For the redirect approach, track the conversion on the thank-you page instead:


{% if page.permalink == '/contact/thank-you/' %}
<script>
  gtag('event', 'generate_lead', { event_category: 'Contact' });
</script>
{% endif %}

With Plausible Analytics (a privacy-focused alternative), use their custom events API:

plausible('ContactFormSubmission');

Tracking form conversions lets you understand which traffic sources generate actual leads β€” crucial for deciding where to invest in content and promotion.


Building a multi-step contact form

For more complex contact flows β€” like a support request form that gathers context before routing to the right team β€” a multi-step form keeps individual screens simple while collecting detailed information.

With Alpine.js and any of the three form services:

<div x-data="{
  step: 1,
  maxStep: 3,
  formData: { name: '', email: '', type: '', message: '' }
}">
  <!-- Progress indicator -->
  <div class="step-indicator">
    <span :class="step >= 1 ? 'active' : ''">1. Contact info</span>
    <span :class="step >= 2 ? 'active' : ''">2. Topic</span>
    <span :class="step >= 3 ? 'active' : ''">3. Message</span>
  </div>

  <!-- Step 1: Basic info -->
  <div x-show="step === 1">
    <input x-model="formData.name" type="text" placeholder="Your name" required>
    <input x-model="formData.email" type="email" placeholder="Email address" required>
    <button @click="step = 2" :disabled="!formData.name || !formData.email">Next β†’</button>
  </div>

  <!-- Step 2: Topic selection -->
  <div x-show="step === 2">
    <div class="topic-options">
      <button @click="formData.type = 'theme'; step = 3"
              :class="formData.type === 'theme' ? 'selected' : ''">
        Theme question
      </button>
      <button @click="formData.type = 'submit'; step = 3"
              :class="formData.type === 'submit' ? 'selected' : ''">
        Submit a theme
      </button>
      <button @click="formData.type = 'other'; step = 3"
              :class="formData.type === 'other' ? 'selected' : ''">
        Something else
      </button>
    </div>
    <button @click="step = 1">← Back</button>
  </div>

  <!-- Step 3: Message -->
  <div x-show="step === 3">
    <textarea x-model="formData.message" rows="6" placeholder="Your message..." required></textarea>
    <div class="form-actions">
      <button @click="step = 2">← Back</button>
      <button type="submit">Send Message</button>
    </div>
  </div>
</div>

The multi-step approach significantly improves completion rates for longer forms by making each individual screen feel simple. Users are less likely to abandon a form when they can see they are progressing through a short sequence of focused questions.

When the form submits, include the type field as a hidden input so your form service records which topic the user selected β€” useful for routing and analytics.


Response time expectations and auto-reply setup

Setting visitor expectations about response time reduces anxiety after form submission. Include clear text on the contact page or thank-you page about when they can expect to hear back: β€œWe typically respond within one business day.” This is better than β€œwe’ll be in touch soon,” which is vague.

Most form services support auto-reply emails β€” an instant confirmation sent to the person who submitted the form. This is valuable because it confirms their message was received and gives them your email for follow-up if needed.

In Formspree, set up an auto-reply from your form settings page. In Netlify Forms, use form notifications with a reply-to template. In Getform, the auto-reply option is under each form’s settings.

Keep auto-replies brief and useful: confirm receipt, set a response time expectation, and optionally link to relevant resources that might answer their question immediately. A well-designed contact form process β€” from the form itself, through the thank-you page, to the auto-reply email β€” reflects on the professionalism of your site and improves the experience for every visitor who reaches out.

Contact forms on Jekyll themes from JekyllHub are often already styled and ready to connect to your chosen service β€” check the theme’s feature list to see if a contact page template is included.

All three services β€” Formspree, Netlify Forms, and Getform β€” have free tiers that handle the submission volumes of most personal and small business sites. The limiting factor is almost never the submission count; it is response time and follow-through. A well-designed contact form that converts visitors into conversations is the start of every customer relationship, partnership, and opportunity that comes through your Jekyll site.


Validating submissions server-side

Client-side validation (HTML5 required attributes and JavaScript checks) improves user experience, but it is not a security control. Anyone can bypass browser validation by sending a POST request directly. For mission-critical contact forms, add server-side validation through your form service’s rules engine.

Formspree allows you to define required fields in your dashboard, reject submissions missing specific keys, and block certain email domains. Netlify Forms supports notification filters that can ignore submissions that look like spam before they reach your inbox. Getform provides webhook delivery with full JSON payloads, which you can process in a Cloudflare Worker to apply custom validation logic before storing or forwarding the submission.

For most personal and small business Jekyll sites, the honeypot approach combined with a form service’s built-in spam filtering is sufficient. Reserve server-side webhook processing for cases where you need routing logic β€” for example, sending support requests to a helpdesk tool and partnership inquiries to a separate inbox β€” or when you require complete audit logging of every submission.

Testing your contact form before launch is non-optional. Submit it yourself with a real email address, verify the thank-you page redirect works, confirm the auto-reply arrives promptly, and check that your form dashboard received the submission with all fields intact. Test from mobile too β€” form inputs and button tap targets behave differently on touch devices and deserve the same scrutiny as desktop.

Share LinkedIn