← All guides
MkDocs

MkDocs contact form without a server

Add a contact form to an MkDocs or Material for MkDocs site with plain HTML. Works on GitHub Pages and any static host. Stored in Frankfurt.

MkDocs turns Markdown into static HTML, and most MkDocs sites are deployed with mkdocs gh-deploy to GitHub Pages or to another file host. Nothing there can receive the POST when someone clicks "Send".

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. Your MkDocs site stays fully static, and you don't need a plugin.

Quick start

  1. Sign in to Formlane, create a form and copy its access key (fl_…).
  2. Add docs/contact.md (below), replace fl_YOUR_ACCESS_KEY and put your own thank-you URL into redirect.
  3. Add docs/thanks.md and, if you like, the small stylesheet.
  4. Run mkdocs build (or mkdocs 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

docs/contact.md
# Contact

Questions? Write us.

<form action="https://smallwerk.com/formlane/api/submit" method="POST" accept-charset="UTF-8" class="contact-form" data-formlane>
  <input type="hidden" name="access_key" value="fl_YOUR_ACCESS_KEY">
  <input type="hidden" name="subject" value="New message from Example Docs">
  <input type="hidden" name="redirect" value="https://example.com/thanks/">
  <!-- 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" class="md-button md-button--primary">Send</button>
  <p class="contact-form-status" role="status" hidden></p>
</form>
docs/thanks.md
---
search:
  exclude: true
---

# Thanks

Thanks for your message. We'll get back to you soon.
mkdocs.yml (only the lines that matter here)
site_name: Example Docs
site_url: https://example.com/
theme:
  name: material
not_in_nav: |
  thanks.md
extra_css:
  - stylesheets/contact-form.css
docs/stylesheets/contact-form.css
.contact-form { display: grid; gap: 0.4rem; max-width: 30rem; }
.contact-form input, .contact-form textarea {
  font: inherit; color: inherit; background: transparent;
  border: 1px solid var(--md-default-fg-color--lighter); border-radius: 0.2rem; padding: 0.4rem;
}
.contact-form button { justify-self: start; margin-top: 0.4rem; }

MkDocs passes raw HTML in Markdown through unchanged, so you don't need an extension like md_in_html for this. Keep a blank line before <form> and after </form>. The form works without JavaScript and with the plain MkDocs theme too; the md-button classes and CSS variables only add the Material look. 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.

The thank-you page

After a successful submission the redirect field sends visitors to the thank-you page. It has to be an absolute http(s) URL. Markdown pages can't read site_url, so write the full URL yourself. Without a redirect, Formlane uses the default redirect from your form settings, or its own hosted thank-you page.

  • With the default use_directory_urls: true, docs/thanks.md becomes /thanks/. With use_directory_urls: false it is /thanks.html.
  • On a GitHub Pages project site, include the repository name: https://user.github.io/repo-name/thanks/.
  • not_in_nav keeps the thank-you page out of the navigation without a build notice, and search: exclude: true keeps it out of Material's search.

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 script shows its own text per error code.

Add the script to mkdocs.yml:

mkdocs.yml
extra_javascript:
  - javascripts/contact-form.js
docs/javascripts/contact-form.js
// Optional: send forms marked with data-formlane via fetch() and show a message
// instead of a page change. Without JavaScript the form still posts normally.
const FORMLANE_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 FORMLANE_FALLBACK = "Sorry, that didn't work. Please try again later.";

function enhanceContactForm(form) {
  if (form.dataset.enhanced) return;
  form.dataset.enhanced = "true";
  const status = form.querySelector(".contact-form-status");
  const button = form.querySelector('button[type="submit"]');

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    button.disabled = true;
    let text = FORMLANE_FALLBACK;
    try {
      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.
      if (json.success) {
        form.reset();
        text = "Thanks! We'll get back to you.";
      } else {
        text = FORMLANE_MESSAGES[json.error] || FORMLANE_FALLBACK;
      }
    } catch {
      // network error: keep the fallback text
    }
    status.textContent = text;
    status.hidden = false;
    button.disabled = false;
  });
}

function initContactForms() {
  document.querySelectorAll("form[data-formlane]").forEach(enhanceContactForm);
}

// Material's instant navigation swaps pages without a full reload.
// document$ emits after every page load, so the form is found on each visit.
if (typeof document$ !== "undefined") {
  document$.subscribe(initContactForms);
} else {
  initContactForms();
}

If you use Material's navigation.instant, pages are swapped without a full reload, and a script that only runs once won't see a form you navigate to. Subscribing to Material's document$ fixes that. If the script doesn't run for any reason, the browser still posts the form normally and the visitor lands on your thank-you page.

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.
  • Material for MkDocs announces its end of life for May 5, 2027 and points to Zensical as its successor. The form itself is plain HTML, so it doesn't depend on the theme; we haven't tested the optional script with Zensical yet.

Alternatives, honestly compared

OptionGood fit whenTrade-off
FormlaneMkDocs 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 MkDocs site is deployed on Netlify anywayOnly works on Netlify hosting, not on GitHub Pages; usage is billed in Netlify's credit plans
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
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)

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 MkDocs send email from a contact form?

Not on its own: MkDocs builds static files. A form backend receives the POST and sends the email for you.

Do I need a plugin or the md_in_html extension?

No. MkDocs passes raw HTML blocks in Markdown through unchanged. md_in_html is only needed if you want Markdown formatting inside the form.

Does it work with Material's instant navigation?

Yes. Without JavaScript or before the script runs, the form posts normally. The optional script subscribes to document$, so it also picks up the form after an instant page change.

Is it safe to put the access key in the page?

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