RG Forms logoRG Forms
Jekyll

Adding a contact form to a Jekyll site

Most Jekyll sites are on GitHub Pages, which means two constraints at once: no server-side code, and no arbitrary plugins in the build. Anything requiring a gem that isn’t on the allowed list is out.

This approach needs neither. It’s an include, a config value, and standard browser JavaScript.

Last updated August 19, 2026

The short answer

Jekyll builds static HTML with no server behind it, so a contact form must POST to an external endpoint. Put the endpoint URL in _config.yml, build the form as an include in _includes/contact-form.html, and reference it with {% include contact-form.html %}. With RG Forms the endpoint is a Google Apps Script web app in your own Google account and submissions land in your own Google Sheet — no plugins are involved, so it works on GitHub Pages' restricted Jekyll build exactly as it does locally.

The setup

  1. Add the endpoint to _config.yml

    Remember that changes to _config.yml aren’t picked up by jekyll serve — restart it after editing.

    _config.yml
    form_endpoint: "https://script.google.com/macros/s/AKfycb.../exec"
    contact_fallback_email: "hello@example.com"
  2. Create the include

    The {% raw %} wrapper around the script isn’t strictly required for this snippet, but it’s the habit worth having: the moment you add a template literal with ${...} or an object literal spanning {{, Liquid will try to parse it and the build will fail with a confusing error.

    _includes/contact-form.html
    <form id="contact-form" class="contact-form"
          data-endpoint="{{ site.form_endpoint }}"
          data-fallback="{{ site.contact_fallback_email }}">
      <label>Name <input name="name" required></label>
      <label>Email <input type="email" name="email" required></label>
      <label>Message <textarea name="message" rows="5" required></textarea></label>
    
      <input type="text" name="_hp" tabindex="-1" autocomplete="off"
             aria-hidden="true" style="position:absolute;left:-9999px">
    
      <button type="submit">Send message</button>
      <p id="form-status" role="status" aria-live="polite"></p>
    </form>
    
    {% raw %}
    <script>
      (function () {
        var form = document.getElementById('contact-form');
        var status = document.getElementById('form-status');
        var button = form.querySelector('button');
    
        form.addEventListener('submit', function (event) {
          event.preventDefault();
          button.disabled = true;
          status.textContent = 'Sending...';
    
          fetch(form.dataset.endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'text/plain' },
            body: JSON.stringify({
              tab: 'contact',
              fields: Object.fromEntries(new FormData(form))
            })
          })
            .then(function (res) { return res.json(); })
            .then(function (data) {
              if (data.result === 'success') {
                form.reset();
                status.textContent = 'Thanks - we will be in touch.';
              } else {
                status.textContent = 'Something went wrong. Please try again.';
              }
            })
            .catch(function () {
              status.textContent = 'Network error. Email us at ' + form.dataset.fallback + '.';
            })
            .finally(function () { button.disabled = false; });
        });
      })();
    </script>
    {% endraw %}
  3. Include it on your contact page

    Works in a layout, a page, or a Markdown file with front matter.

    contact.md
    ---
    layout: page
    title: Contact
    permalink: /contact/
    ---
    
    Have a question? Send a message and we'll get back to you.
    
    {% include contact-form.html %}

GitHub Pages notes

Nothing here needs a plugin, so the restricted GitHub Pages build handles it fine. Custom domains and HTTPS are unaffected — both sides are HTTPS, so no mixed-content warnings.

If you’re also using a custom _config.yml for local development, remember that Pages builds only ever read the committed one. A missing form_endpoint there produces an empty data-endpoint and a form that silently does nothing, which is an unpleasant five minutes to debug.

Confirm the endpoint works

Test the endpoint from your terminal
curl -L -X POST "https://script.google.com/macros/s/AKfycb.../exec" \
  -H "Content-Type: text/plain" \
  -d '{"tab":"contact","fields":{"name":"Ada","email":"ada@example.com","message":"Hello"}}'

# → {"result":"success"}   and a new row appears in your Google Sheet

Common questions

Do I need a Jekyll plugin?

No. It’s an include and a config value — both core Jekyll, both allowed on GitHub Pages.

Why did my build break after I edited the script?

Almost certainly Liquid parsing something in your JavaScript — {{ in an object literal or a ${...} template literal. Keep the script inside {% raw %} and pass dynamic values in through data- attributes.

Can I use this with a Jekyll theme gem?

Yes. Create _includes/contact-form.html in your own repo and it takes precedence over anything the theme ships.

Is there a no-JavaScript version?

Not with this endpoint — it expects a fetch with a JSON body. Services that accept a plain action= POST and redirect are the ones to look at if no-JS support is a hard requirement.

More answers in the full FAQ.

Your endpoint goes live in ~90 seconds