← All guides
Cloudflare Pages

Cloudflare Pages contact form

Add a contact form to your Cloudflare Pages site: post plain HTML to Formlane. No Pages Function, no Worker, no mail API. Submissions stored in Frankfurt.

Cloudflare Pages serves your static build, so a plain <form> has nothing to post to. Cloudflare's own route is a Pages Function or the Static Forms plugin: you write the handler, then connect storage and an email service yourself. With Formlane, the form posts straight to one endpoint. Formlane filters spam, stores each submission and emails it to your account email. It works the same for plain HTML and for static builds from Astro, Hugo or Eleventy.

Steps

  1. Sign in to Formlane, create a form and copy its access key (fl_…).
  2. Paste the form below into your contact page (for example contact.html or the contact component of your framework) and replace the access key.
  3. Add a thank-you page and set the hidden redirect field to its absolute URL.
  4. In the form settings, add your custom domain and your-project.pages.dev to the allowed domains.
  5. Push to the branch Cloudflare Pages deploys, submit the live form once and check your inbox and dashboard.

Code

contact.html
<form action="https://smallwerk.com/formlane/api/submit" method="POST" accept-charset="UTF-8">
  <input type="hidden" name="access_key" value="fl_YOUR_ACCESS_KEY">
  <input type="hidden" name="subject" value="New message from example.com">
  <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">

  <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">Send</button>
</form>

The key is public by design: it only allows sending submissions to this one form. To be safe, restrict the form to your domains in its settings.

thanks/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="robots" content="noindex">
  <title>Thanks for your message</title>
</head>
<body>
  <h1>Thanks!</h1>
  <p>Your message has been sent. We'll get back to you soon.</p>
  <p><a href="/">Back to the homepage</a></p>
</body>
</html>

Formlane or a Pages Function?

Both work. A Pages Function gives you full control and keeps the data in your Cloudflare account, but you maintain the code. Formlane is the shorter path if you only need a working contact form.

Pages Function / Static Forms pluginFormlane
Code to writeA handler in functions/ (deployed with your site)None, the form posts to the Formlane endpoint
Email to youConnect a mail API or Email Routing yourselfEmail notification for each submission (to your account email), reply-to set to the sender
Storing submissionsSet up KV, D1 or an external databaseDashboard with CSV export
SpamBuild your own checks or add TurnstileHoneypot, rate limit and content rules, see below

Preview deployments and allowed domains

Cloudflare Pages gives every preview deployment its own address under your-project.pages.dev, for example abc123.your-project.pages.dev. Allowed domains match subdomains, so one entry for your-project.pages.dev covers production and all previews. Add your custom domain as a second entry. Don't add pages.dev on its own: that would accept submissions from every Pages site.

Formlane checks the Origin header of each submission (the Referer if there is no Origin). A post from a domain that isn't on the list is rejected with origin_not_allowed. With an empty list, the form accepts submissions from any site.

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

contact.html (before </body>)
<script>
  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.",
    origin_not_allowed: "This form only accepts messages from its own website.",
  };
  const form = document.querySelector('form[action*="formlane"]');
  if (form) form.addEventListener("submit", async (e) => {
    e.preventDefault();
    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.
    form.outerHTML = json.success
      ? "<p>Thanks! We'll get back to you.</p>"
      : "<p>" + (MESSAGES[json.error] || "Sorry, that didn't work. Please try again later.") + "</p>";
  });
</script>

Spam protection without a captcha

  • Honeypot: the hidden botcheck checkbox (_gotcha and _honey also work). 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 domains in its settings, as described above.

Where the data is stored

Submissions are stored in Frankfurt, in a Supabase database in the eu-central-1 region. The app runs on Vercel with compute in Frankfurt; static assets and the edge network are global. 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.

FAQ

Do I need a Pages Function or a Worker?

No. The form posts directly to Formlane, so your Cloudflare Pages project stays fully static. You don't need a functions folder, Wrangler or a mail API.

Do submissions from preview deployments work?

Yes, if your-project.pages.dev is on the form's allowed domains. Subdomains match, so every preview address under it is accepted. With an empty list, all sites are accepted.

Does it work with Astro, Hugo or Eleventy on Cloudflare Pages?

Yes. Those tools build static HTML, and the form is plain HTML. Put it in a page or component; the build output needs no server code.

Will I get an email for each message?

Yes. Formlane sends an email notification for each submission to your account email, with reply-to set to the sender's address. Every submission is also in your Formlane dashboard, where you can export it as CSV. Suspected spam and test submissions from the dashboard are not emailed.

Can visitors upload files?

Not yet. Formlane accepts text fields only.

What happens at the monthly limit?

New submissions are rejected with a limit_reached error until the next calendar month or until you upgrade. Submissions you already have are kept.

Get your access key

Free for 250 submissions a month. Submissions stored in Frankfurt.

Get your access key