Skip to content
FormSubmit

React

How to build a React contact form that actually sends email

A copy-paste React contact form component that sends submissions to your inbox via a form backend — no Express server, no EmailJS keys in the browser.

1 min readUpdated Sep 28, 2026

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:

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>
  );
}

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:

json
{ "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

ContactForm.tsx
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.

Frequently asked questions

Is it safe to put the endpoint URL in client-side code?

Yes. The endpoint only accepts submissions; it can't read data. Use the domain allowlist and spam protection to stop abuse.

Does this work with Vite, Create React App and Remix?

Yes — it's plain React and fetch, so it works in any React setup.

Keep reading

Your form backend is 60 seconds away

Sign in with Google, create a form, paste the endpoint. Free forever for small sites — no credit card.