Gatsby contact form setup in four minutes (no Netlify required)
April 5, 2026 · 6 min read
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:
- The
actionattribute is a fullhttps://URL, not a relative path - The
methodisPOST, not missing (defaulting to GET) - No
onSubmithandler is intercepting and preventing the default - No
gatsby-plugin-catch-linksis 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-netlifyattributes — 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.
More posts