Jekyll contact form with an include
A Jekyll contact form is an include and a site variable. It needs no plugin, and it works the same whether you build locally, on Netlify, or on GitHub Pages.
The include, and one line of config
Put the endpoint in _config.yml:
form_endpoint: https://api.formroute.dev/f/site_xyz
Then _includes/contact-form.html:
<form action="{{ site.form_endpoint }}" method="POST">
<label>
Email
<input name="email" type="email" required>
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button>{{ include.button | default: "Send" }}</button>
</form>
And drop it into any page or post:
{% include contact-form.html %}
_config.yml is the one file Jekyll does not watch. Change the endpoint and you have to restart jekyll serve, or you will spend twenty minutes wondering why the form still posts to the old URL.
Include parameters turn one file into several forms
That include.button is the piece worth knowing. Jekyll passes named parameters into an include and exposes them under the include namespace, so one file covers every form on the site:
{% include contact-form.html button="Request a quote" %}
{% include contact-form.html button="Join the waitlist" %}
Extend it as far as it stays readable. A second endpoint, a heading, an extra field behind a flag:
<form action="{{ include.endpoint | default: site.form_endpoint }}" method="POST">
{% if include.show_company %}
<input name="company" placeholder="Company">
{% endif %}
<!-- email, message and button as above -->
</form>
The | default: filter is what keeps existing call sites working when you add a parameter. Without it, adding an option means editing every page that already used the include.
No plugin can process a form submission
There is no Jekyll plugin in any of this, and there cannot be. Plugins run at build time. A submission arrives minutes or months after the build finished, so nothing in the Jekyll pipeline is running to receive it. Anything advertising otherwise is generating markup, which an include already does.
Plugins do get restricted when GitHub builds your site for you, but since a contact form needs none, that limit never touches it. The GitHub Pages contact form covers the whitelist and the rest of what that host does not run.
Testing it before you deploy
jekyll serve gives you http://localhost:4000, and a form there posts to the real endpoint over the network. Two consequences surprise people:
- The submissions are real. They land in your dashboard and count against your quota exactly like production ones. Use an obvious test address so you can find and delete them afterwards.
- Some backends reject an origin you have not registered. FormRoute does not, so a localhost post works with no extra setup, but a service that scopes forms to one domain will fail here in a way that looks like broken markup.
Test with the network tab open and read the response. A 200 with an empty inbox is a delivery problem; a 4xx is a validation problem. They look identical from the page and have nothing to do with each other.
Jekyll produces files, so required is a courtesy to the honest visitor and the rules have to hold at the endpoint instead. What a form backend does with them covers that half, including how spam gets filtered before delivery.
When the include is not the right tool
If the form needs live validation, a character counter, or a success state that does not navigate, the include stays and a small script joins it. The vanilla JavaScript contact form is that script, and it drops into the include unchanged. Moving to a generator with components instead of includes is a bigger change: the Astro contact form is the closest equivalent.
Everything above ships from _site, which is a folder. FormRoute is the only part that has to be running when someone hits Send, and access is private for now: request an invite.