All resources
Static site forms

React Contact Form Without a Backend (Two Patterns)

Build a working React contact form with no API route and no server: a plain-action version with zero extra JavaScript, and a fetch version with loading and success states.

The ShipMyForm team

· 2 min read

A React contact form doesn't need a backend. POST the form to a hosted endpoint and the service behind it stores the submission, filters spam, and emails you — your app stays static, deployable to any CDN, with no API route, no Express server, and no email credentials anywhere near client code.

Below are the two patterns that cover essentially every React contact form, both against the same endpoint: the zero-JavaScript version, and the fetch version with proper loading and success states.

Pattern 1: the plain form (zero extra JavaScript)

React renders HTML, and HTML forms already know how to submit themselves. For a simple contact page this is the entire component:

jsx
export function ContactForm() {
  return (
    <form action="https://shipmyform.com/f/YOUR_FORM_ID" method="POST">
      <input type="text" name="name" placeholder="Your name" required />
      <input type="email" name="email" placeholder="[email protected]" required />
      <textarea name="message" placeholder="How can we help?" required />
      {/* honeypot: hidden from humans, irresistible to bots */}
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
      <input type="hidden" name="_redirect" value="https://yoursite.com/thanks" />
      <button type="submit">Send</button>
    </form>
  );
}

Every name attribute becomes a field in the email you receive; _redirect sends the visitor to your thank-you page. No state, no handlers, works even if your JavaScript bundle fails to load. Don't underestimate this version — for a contact page it's often the right amount of engineering.

Pattern 2: fetch, with loading and success states

When you want to stay on the page — inline "thanks" message, disabled button while sending — submit the same endpoint with FormData:

jsx
import { useState } from "react";

export function ContactForm() {
  const [status, setStatus] = useState("idle"); // idle | sending | sent | error

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus("sending");
    try {
      const res = await fetch("https://shipmyform.com/f/YOUR_FORM_ID", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(e.currentTarget),
      });
      setStatus(res.ok ? "sent" : "error");
    } catch {
      setStatus("error");
    }
  }

  if (status === "sent") return <p>Thanks — we'll get back to you soon.</p>;

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="name" placeholder="Your name" required />
      <input type="email" name="email" placeholder="[email protected]" required />
      <textarea name="message" placeholder="How can we help?" required />
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send"}
      </button>
      {status === "error" && <p role="alert">Something went wrong — try again.</p>}
    </form>
  );
}

Two React-specific notes worth internalizing:

  • You don't need controlled inputs to submit. new FormData(e.currentTarget) reads values straight from the DOM — no useState per field, no re-render per keystroke. Reach for controlled inputs only when something must react while typing (live validation, character counts).
  • Accept: application/json tells the endpoint to answer with { ok: true } instead of redirecting — that's what makes the inline success state possible. The full pattern, including error details and validation messages, is in submit a form with fetch.

What you're not building

The reason "React contact form" tutorials balloon to an hour is everything around the component. With a form endpoint, that work is the service's problem, not your bundle's:

ConcernWhere it's handled
Email sending + deliverability (SPF/DKIM)Backend's authenticated domain
SpamServer-side: honeypot, rate limits, ML + LLM screening — no CAPTCHA
Storage & searchHosted inbox with CSV export
ValidationServer-side rules, enforced even with JS off
Routing to Slack / Sheets / NotionToggles, not integrations

As of 2026, ShipMyForm's free plan covers 100 submissions a month with all of the above included — enough for most portfolio and product-site contact forms, with React-specific docs for the details.

Never send email from React directly:

Any library that "sends email from the frontend" ships its credentials in your bundle, where anyone can lift them and spend your quota (or your sending reputation). If a tutorial has an API key in a React component, close the tab. POST to an endpoint; keep secrets server-side — someone else's server, ideally.

Next steps

Frequently asked questions

Can a React contact form work without a backend?
Yes. The form POSTs to a hosted form endpoint instead of your own server — the service behind the endpoint stores the submission, filters spam, and emails you. Your React app stays fully static, which is why this pattern is the standard for Vite and Create React App sites deployed to static hosts.
How do I send a React form to my email?
Point the form at a form backend endpoint, either via the form's action attribute or a fetch call with a FormData body. The backend delivers each submission to your inbox from an authenticated sending domain. Never call an email API directly from React — that puts sending credentials in client code where anyone can copy them.
Do I need controlled components for a contact form?
Not for submission. FormData reads input values straight from the DOM at submit time, so uncontrolled inputs with name attributes are enough — less state, less re-rendering. Use controlled inputs only when you need live behavior like character counts or inline validation as the user types.
How do I handle spam on a React contact form?
Server-side, so bots can't bypass it by skipping your JavaScript. A form backend runs honeypots, rate limiting, and machine-learning classification on every submission before it reaches you. Client-side tricks alone don't hold up, because bots can POST to your endpoint directly.
Does this work with Next.js too?
Yes — the same endpoint works in Next.js client components, or you can use Server Actions if you're running a Node server anyway. For fully static Next.js exports, the endpoint pattern is the one that keeps working without infrastructure.

Related guides