Gatsby contact form setup in four minutes (no Netlify required)

April 5, 2026 · 6 min read

Abstract pattern — Gatsby contact form wired to a hosted endpoint

Gatsby isn't the hot framework anymore, and that's fine. It's still running thousands of production sites — blogs, docs sites, agency portfolios, company marketing pages — and most of them are not going to be rewritten into Next or Astro this quarter. If you're maintaining one, you still need a working contact form, and you probably don't want to drag in a third of the React ecosystem to do it.

Here's the short version that works on any Gatsby site, on any host, without installing a plugin.

What we're building

A /contact page on your Gatsby site with:

  • A real HTML form (no gatsby-plugin-forms, no React state, no custom hooks)
  • A hidden honeypot field for spam protection
  • A custom thank-you page
  • Email and webhook notifications handled by a hosted backend
  • Zero new dependencies in your package.json

Step 1 — Get an endpoint

Sign up for any hosted form backend. I'll use FormTo. Create a form in the dashboard, grab the URL:

https://api.formto.dev/f/your-form-slug

Add it to your Gatsby site config so the URL lives in one place. In gatsby-config.js:

module.exports = {
  siteMetadata: {
    title: 'Your site',
    siteUrl: 'https://yoursite.com',
    contactEndpoint: 'https://api.formto.dev/f/your-form-slug',
  },
  // ... rest of config
}

Now any page can access it via a GraphQL query on siteMetadata.

Step 2 — Create the contact page

Gatsby pages live in src/pages/. Create src/pages/contact.js:

import * as React from 'react'
import { graphql, useStaticQuery } from 'gatsby'

const ContactPage = () => {
  const data = useStaticQuery(graphql`
    query {
      site {
        siteMetadata {
          contactEndpoint
          siteUrl
        }
      }
    }
  `)

  const { contactEndpoint, siteUrl } = data.site.siteMetadata

  return (
    <main style={{ maxWidth: 560, margin: '0 auto', padding: '4rem 1.5rem' }}>
      <h1>Say hi</h1>
      <p style={{ color: '#555' }}>
        Real replies from real humans, usually within a day.
      </p>

      <form
        method="POST"
        action={contactEndpoint}
        style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginTop: '2rem' }}
      >
        <label>
          Name
          <input type="text" name="name" required style={{ width: '100%' }} />
        </label>

        <label>
          Email
          <input type="email" name="email" required style={{ width: '100%' }} />
        </label>

        <label>
          Message
          <textarea name="message" rows="5" required style={{ width: '100%' }} />
        </label>

        {/* honeypot */}
        <input
          type="text"
          name="website"
          tabIndex={-1}
          autoComplete="off"
          style={{ position: 'absolute', left: '-9999px' }}
          aria-hidden="true"
        />

        <input type="hidden" name="_redirect" value={`${siteUrl}/thanks/`} />

        <button type="submit">Send message</button>
      </form>
    </main>
  )
}

export default ContactPage

export const Head = () => <title>Contact</title>

Gatsby's newer Head API replaces the old react-helmet pattern. If you're on an older Gatsby (v4 or earlier), use react-helmet instead — the form itself is unchanged.

Step 3 — The thank-you page

Create src/pages/thanks.js:

import * as React from 'react'
import { Link } from 'gatsby'

const ThanksPage = () => (
  <main style={{ maxWidth: 560, margin: '0 auto', padding: '6rem 1.5rem', textAlign: 'center' }}>
    <h1>Got it.</h1>
    <p style={{ color: '#555' }}>We'll reply within a day or two.</p>
    <Link to="/">← Back home</Link>
  </main>
)

export default ThanksPage

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

Build and deploy:

gatsby build
# deploy the public/ folder

Your contact form is live.

Why not use gatsby-plugin-contact-form or similar?

There are a handful of Gatsby plugins that promise to handle contact forms for you. I've tried a few. Most of them either:

  • Wrap an existing form service (in which case you're adding abstraction you don't need)
  • Require you to sign up for a specific vendor the plugin author prefers
  • Haven't been updated in two years and have open security issues
  • Add several MB to your node_modules for what is fundamentally a <form> tag

The hosted-endpoint approach is a plugin-free, vendor-neutral version of the same idea. You can swap backends without touching any Gatsby code. You can migrate the entire site to a different framework and the form keeps working.

The Gatsby-specific gotcha: client-side routing

Gatsby is an SPA under the hood. When a visitor clicks a link to /contact/, Gatsby's client router intercepts the click and loads the page without a full page refresh.

When the form submits, the browser performs a native POST to the external backend URL, which is not something Gatsby intercepts — the browser does its normal thing, POSTs the form, and navigates to the backend's response (or the _redirect URL you set).

This works correctly out of the box. But if you see weird behavior where the form seems to submit but no email arrives, double-check that:

  1. The action attribute is a full https:// URL, not a relative path
  2. The method is POST, not missing (defaulting to GET)
  3. No onSubmit handler is intercepting and preventing the default
  4. No gatsby-plugin-catch-links is trying to route the POST through the client router

If you've somehow added client-side interception to the form (common when people copy React patterns from Next.js tutorials), the POST will never actually fire. Remove the interception, let the browser handle it natively.

Deploying on Netlify, Cloudflare Pages, or anywhere else

The form setup is host-agnostic. It will work on:

  • Netlify (without data-netlify attributes — you're bypassing Netlify Forms on purpose)
  • Cloudflare Pages
  • Vercel (as a static Gatsby build)
  • GitHub Pages
  • S3 + CloudFront
  • Your own server serving public/
  • Any CDN

The hosted backend doesn't care where your HTML is served from. That portability is the main reason I prefer this approach over Netlify's native form handling — see the Netlify Forms migration post for the full argument.

Adding the form to an MDX page

Gatsby users often have MDX blog posts or landing pages and want to drop a contact form into the middle of one. The trick is to create a React component for the form and import it into the MDX:

// src/components/ContactForm.js
import * as React from 'react'
import { useStaticQuery, graphql } from 'gatsby'

const ContactForm = () => {
  const data = useStaticQuery(graphql`
    query {
      site {
        siteMetadata {
          contactEndpoint
          siteUrl
        }
      }
    }
  `)

  const { contactEndpoint, siteUrl } = data.site.siteMetadata

  return (
    <form method="POST" action={contactEndpoint}>
      {/* ...same form as above... */}
    </form>
  )
}

export default ContactForm

Then in any .mdx file:

# Come work with us

Here's the easy way to reach us:

<ContactForm />

Done. The form renders inside your MDX content, inherits your page's layout, and still posts to the hosted backend.


Create a free form, paste the URL into your gatsby-config.js, and your contact page is live. No plugin, no vendor lock-in, no Gatsby-specific footguns.

For the same setup on other frameworks: Astro, Next.js, SvelteKit, Nuxt, Hugo, Eleventy, Webflow, and Framer.

← All posts