Docusaurus contact form without a server (works on GitHub Pages)
Add a contact or feedback form to a Docusaurus site with one React component and one config field. Works on GitHub Pages. Stored in Frankfurt.
Docusaurus builds a static site, and most Docusaurus sites are deployed to GitHub Pages or another file host. Nothing there can receive the POST when someone clicks "Send". Docs sites still need a way to get in touch: a contact page, a "request a feature" form or a feedback form for a product.
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 Docusaurus site stays fully static.
Quick start
- Sign in to Formlane, create a form and copy its access key (
fl_…). - Add
customFields.formlaneKeytodocusaurus.config.jsand check thaturlis your site's address. - Add
src/components/ContactForm.js(below) and use it insrc/pages/contact.mdx. - Add
src/pages/thanks.md, runnpm run build && npm run serve, send a test message and check your inbox and the Formlane dashboard. - In the form settings, restrict the form to your production domain, so forms on other websites can't post to it.
Code
const config = {
url: 'https://example.com',
baseUrl: '/',
customFields: {
formlaneKey: 'fl_YOUR_ACCESS_KEY',
},
// …the rest of your config
};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. customFields is the place Docusaurus provides for your own values; components read it with useDocusaurusContext().
import React from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useBaseUrl from '@docusaurus/useBaseUrl';
export default function ContactForm() {
const {siteConfig} = useDocusaurusContext();
const thanksUrl = useBaseUrl('/thanks', {absolute: true});
return (
<form action="https://smallwerk.com/formlane/api/submit" method="POST" acceptCharset="UTF-8">
<input type="hidden" name="access_key" value={siteConfig.customFields.formlaneKey} />
<input type="hidden" name="subject" value={`New message from ${siteConfig.title}`} />
<input type="hidden" name="redirect" value={thanksUrl} />
{/* Honeypot: hidden from people, leave it unchecked */}
<input type="checkbox" name="botcheck" style={{display: 'none'}} tabIndex={-1} autoComplete="off" />
<label htmlFor="cf-name">Name</label>
<input id="cf-name" type="text" name="name" autoComplete="name" required />
<label htmlFor="cf-email">Email</label>
<input id="cf-email" type="email" name="email" autoComplete="email" required />
<label htmlFor="cf-message">Message</label>
<textarea id="cf-message" name="message" rows={5} required />
<button type="submit" className="button button--primary">Send</button>
</form>
);
}--- title: Contact --- import ContactForm from '@site/src/components/ContactForm'; # Contact Questions? Write us. <ContactForm />
--- title: Thanks --- # Thanks Thanks for your message. We'll get back to you soon.
You can also put <ContactForm /> inside any doc page: import it at the top of the .mdx file the same way. The buttons use the button button--primary classes of the default theme, so they match your site.
The thank-you page
After a successful submission the redirect field sends visitors to /thanks. It has to be an absolute http(s) URL, which is why the component uses useBaseUrl('/thanks', {absolute: true}): it joins url, baseUrl and the path. Without a redirect, Formlane uses the default redirect from your form settings, or its own hosted thank-you page.
- On a GitHub Pages project site,
baseUrl: '/repo-name/'makes the redirecthttps://example.com/repo-name/thanks. - The value comes from
url, so duringnpm startthe redirect also points to your live site. Test the full flow withnpm run build && npm run serveor on a preview deploy.
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 component shows its own text per error code.
import React, {useState} from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
const 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 FALLBACK = "Sorry, that didn't work. Please try again later.";
export default function ContactFormInline() {
const {siteConfig} = useDocusaurusContext();
const [status, setStatus] = useState('idle'); // idle | sending | sent | error
const [error, setError] = useState('');
async function handleSubmit(event) {
event.preventDefault();
setStatus('sending');
try {
const res = await fetch(event.currentTarget.action, {
method: 'POST',
headers: {Accept: 'application/json'},
body: new FormData(event.currentTarget),
});
const json = await res.json().catch(() => ({}));
// Spam caught by the honeypot or the content rules also gets success: true.
if (json.success) return setStatus('sent');
setError(MESSAGES[json.error] || FALLBACK);
} catch {
setError(FALLBACK);
}
setStatus('error');
}
if (status === 'sent') return <p>Thanks! We'll get back to you.</p>;
return (
<form action="https://smallwerk.com/formlane/api/submit" method="POST" onSubmit={handleSubmit}>
<input type="hidden" name="access_key" value={siteConfig.customFields.formlaneKey} />
<input type="checkbox" name="botcheck" style={{display: 'none'}} tabIndex={-1} autoComplete="off" />
<label htmlFor="cfi-name">Name</label>
<input id="cfi-name" type="text" name="name" autoComplete="name" required />
<label htmlFor="cfi-email">Email</label>
<input id="cfi-email" type="email" name="email" autoComplete="email" required />
<label htmlFor="cfi-message">Message</label>
<textarea id="cfi-message" name="message" rows={5} required />
<button type="submit" className="button button--primary" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send'}
</button>
{status === 'error' && <p role="alert">{error}</p>}
</form>
);
}Without JavaScript, the browser still posts the form normally.
Spam protection without a captcha
- Honeypot: the hidden
botcheckcheckbox. 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) andapplication/json. - File uploads are not supported yet. If your form needs attachments, use one of the alternatives below.
Alternatives, honestly compared
| Option | Good fit when | Trade-off |
|---|---|---|
| Formlane | Docusaurus on GitHub Pages or any static host; you want email alerts and a dashboard without running code | No file uploads yet; on the Free plan, notification emails and the hosted thank-you page carry a "Powered by Formlane" link |
| Netlify Forms | Your Docusaurus site is deployed on Netlify anyway | Only works on Netlify hosting, not on GitHub Pages. React-rendered forms need extra setup (a hidden form-name field); usage is billed in Netlify's credit plans |
| GitHub Issues or Discussions link | Your readers are developers with GitHub accounts | Not a form: people need a GitHub account, and messages are public |
| Your own serverless function (Netlify/Vercel/Cloudflare) | You want full control over storage and email | GitHub Pages can't run it, so the function lives on a second host; you maintain it, an email provider account and spam protection yourself |
| Formspree | You want the best-known form backend and its integrations | Free plan: 50 submissions a month (plans page, checked 26.10.2026) |
| Web3Forms | You need many forms on the free tier | Free 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.
Related guides
FAQ
Can Docusaurus send email from a contact form?
Not on its own: Docusaurus builds static files, and GitHub Pages runs no server code. A form backend receives the POST and sends the email for you.
Does this need a Docusaurus plugin or swizzling?
No. It is one React component in src/components and an MDX page. Nothing is swizzled.
Can I use it as a feedback form on doc pages?
Yes. Import the component in any .mdx doc and add a hidden field, for example <input type="hidden" name="page" value="…">, so you know which page the feedback is about.
Is it safe to put the access key in docusaurus.config.js?
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