To send a React form to email, submit its FormData with fetch to a form backend endpoint and render the result. The backend (FormSubmit) stores the submission, filters spam and emails you. You don't need an Express server, and you don't expose any email API keys in the browser.
The component
This works with Vite, Remix, Gatsby, Create React App or any other React setup:
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>
);
}How it works
Collecting values
new FormData(e.currentTarget) reads every input with a name — uncontrolled inputs keep the component tiny. If you prefer controlled inputs or a form library, build an object and send JSON instead.
Asking for JSON
The Accept: application/json header switches FormSubmit from redirect mode to API mode. A successful response looks like:
{ "ok": true, "id": "r8T2kLm0Qa9zXc1V", "message": "Thanks! Your submission has been received." }Errors return ok: false with a stable error.code — rate_limited, captcha_failed, domain_not_allowed and others listed in the endpoint reference.
Spam protection
The hidden _gotcha input catches bots that fill every field, and _ts lets FormSubmit discard submissions that arrive impossibly fast. Neither affects real users.
With react-hook-form and zod
const onSubmit = handleSubmit(async (values) => {
const res = await fetch("https://formsubmit.app/f/YOUR_FORM_ID", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ ...values, _ts: renderedAt }),
});
const json = await res.json();
if (!json.ok) setError("root", { message: json.error.message });
});Adding file uploads
Add <input type="file" name="attachment" /> and keep using FormData — the browser sends multipart automatically. For files over a few megabytes, use direct uploads.
Checklist before launch
- Add your domain to the allowlist.
- Customise the notification subject with
{{name}}-style variables in email settings. - Turn on an autoresponder so people know you received their message.