Gatsby sites are static, so point the contact form at a FormSubmit endpoint and submit it with fetch. No Gatsby Functions, no serverless email code.
Component
src/components/ContactForm.tsx:
import { useState, type FormEvent } from "react";
const ENDPOINT = "https://formsubmit.app/f/YOUR_FORM_ID";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
const [error, setError] = useState("");
const [renderedAt] = useState(() => Date.now());
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
const data = new FormData(e.currentTarget);
data.set("_ts", String(renderedAt)); // helps FormSubmit's time-based spam check
try {
const res = await fetch(ENDPOINT, {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.error?.message ?? "Something went wrong");
setStatus("success");
e.currentTarget.reset();
} catch (err) {
setError((err as Error).message);
setStatus("error");
}
}
if (status === "success") {
return <p className="text-green-700">Thanks! We'll be in touch soon.</p>;
}
return (
<form onSubmit={onSubmit} className="mx-auto max-w-lg space-y-5">
<div className="space-y-1.5">
<label htmlFor="fs-name" className="block text-sm font-medium text-gray-900">Name</label>
<input id="fs-name" type="text" name="name" placeholder="Jane Doe" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
</div>
<div className="space-y-1.5">
<label htmlFor="fs-email" className="block text-sm font-medium text-gray-900">Email</label>
<input id="fs-email" type="email" name="email" placeholder="jane@example.com" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
</div>
<div className="space-y-1.5">
<label htmlFor="fs-message" className="block text-sm font-medium text-gray-900">Message</label>
<textarea id="fs-message" name="message" rows={5} placeholder="How can we help?" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"></textarea>
</div>
{/* Honeypot: bots fill this in, humans never see it */}
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
{status === "error" && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={status === "sending"} className="inline-flex w-full items-center justify-center rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50">
{status === "sending" ? "Sending…" : "Send"}
</button>
</form>
);
}Page
import * as React from "react";
import ContactForm from "../components/ContactForm";
export default function ContactPage() {
return (
<main style={{ maxWidth: 560, margin: "0 auto", padding: "4rem 1rem" }}>
<h1>Contact</h1>
<ContactForm />
</main>
);
}
export const Head = () => <title>Contact</title>;No-JavaScript fallback
Add action and method="POST" to the form element so it still submits during server-side rendering or if hydration fails.
Next steps
Add your domain to the allowlist and connect an integration such as Slack.