← All guides
Gatsby

Gatsby contact form without a server or Gatsby Functions

Add a contact form to a Gatsby site with one React component. No Gatsby Functions, works on any static host. Stored in Frankfurt.

Gatsby builds a static site. Once it is deployed to GitHub Pages, Cloudflare Pages, S3 or any other file host, nothing on that host can receive the POST when someone clicks "Send". Gatsby Functions could, but only on hosts that run them, and you'd still need an email provider and spam protection.

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 Gatsby site stays fully static.

Quick start

  1. Sign in to Formlane, create a form and copy its access key (fl_…).
  2. Set siteUrl in gatsby-config.js and put the key in .env.production and .env.development.
  3. Add src/components/contact-form.js (below) and use it on a contact page.
  4. Add src/pages/thanks.js, run gatsby build && gatsby 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

gatsby-config.js
module.exports = {
  siteMetadata: {
    title: "Example",
    siteUrl: "https://example.com", // your site's address, no trailing slash
  },
};
.env.production (and the same line in .env.development)
GATSBY_FORMLANE_KEY=fl_YOUR_ACCESS_KEY

Gatsby puts variables that start with GATSBY_ into the browser bundle at build time. That is fine here: the access key is public by design, it only allows sending submissions to this one form. Restart gatsby develop after you change an .env file.

src/components/contact-form.js
import * as React from "react";
import { graphql, useStaticQuery, withPrefix } from "gatsby";

export default function ContactForm() {
  const { site } = useStaticQuery(graphql`
    query {
      site {
        siteMetadata {
          title
          siteUrl
        }
      }
    }
  `);
  const { title, siteUrl } = site.siteMetadata;

  return (
    <form action="https://smallwerk.com/formlane/api/submit" method="POST" acceptCharset="UTF-8">
      <input type="hidden" name="access_key" value={process.env.GATSBY_FORMLANE_KEY} />
      <input type="hidden" name="subject" value={`New message from ${title}`} />
      <input type="hidden" name="redirect" value={siteUrl + withPrefix("/thanks/")} />
      {/* 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">Send</button>
    </form>
  );
}
src/pages/contact.js
import * as React from "react";
import ContactForm from "../components/contact-form";

export default function ContactPage() {
  return (
    <main>
      <h1>Contact</h1>
      <p>Questions? Write us.</p>
      <ContactForm />
    </main>
  );
}

export const Head = () => <title>Contact</title>;
src/pages/thanks.js
import * as React from "react";

export default function ThanksPage() {
  return (
    <main>
      <h1>Thanks</h1>
      <p>Thanks for your message. We'll get back to you soon.</p>
    </main>
  );
}

export const Head = () => <title>Thanks</title>;

The form is plain HTML rendered by React, with no onSubmit handler. Gatsby writes it into the static HTML at build time, so it also works before the JavaScript has loaded, or without JavaScript.

The thank-you page

After a successful submission the redirect field sends visitors to /thanks/. It has to be an absolute http(s) URL, so the component joins siteUrl with withPrefix("/thanks/"). 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, set pathPrefix: "/repo-name" and build with gatsby build --prefix-paths. The redirect becomes https://example.com/repo-name/thanks/.
  • The value comes from siteUrl, so during gatsby develop the redirect also points to your live site, not to localhost. Test the full flow with gatsby build && gatsby serve or 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.

src/components/contact-form-inline.js
import * as React from "react";

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.",
};

export default function ContactFormInline() {
  const [status, setStatus] = React.useState("idle"); // idle | sending | sent | error
  const [error, setError] = React.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] || "Sorry, that didn't work. Please try again later.");
      setStatus("error");
    } catch {
      setError("Sorry, that didn't work. Please try again later.");
      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={process.env.GATSBY_FORMLANE_KEY} />
      <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" 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. Add the hidden redirect field from the first component if you want the thank-you page in that case too.

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.

Alternatives, honestly compared

OptionGood fit whenTrade-off
FormlaneGatsby on GitHub Pages, Cloudflare 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 Gatsby site is deployed on Netlify anywayOnly works on Netlify hosting. Netlify detects forms in the deployed HTML, so React-rendered forms need extra setup (a hidden form-name field); usage is billed in Netlify's credit plans
Gatsby Functions + an email APIYour host runs Gatsby Functions and you want full controlNot available on plain static hosts like GitHub Pages; you maintain the function, 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 a Gatsby site send email from a contact form?

Not on its own once it is deployed as static files. A form backend receives the POST and sends the email for you, so you don't need Gatsby Functions or a server.

Do I need a Gatsby plugin?

No. The component only uses useStaticQuery and withPrefix from Gatsby itself and a normal HTML form.

Why does the redirect point to my live site during gatsby develop?

The redirect is built from siteUrl in gatsby-config.js. Test the whole flow with gatsby build && gatsby serve or on a preview deploy.

Is it safe to put the access key in a GATSBY_ variable?

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