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:
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:
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 — nouseStateper field, no re-render per keystroke. Reach for controlled inputs only when something must react while typing (live validation, character counts). Accept: application/jsontells 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:
| Concern | Where it's handled |
|---|---|
| Email sending + deliverability (SPF/DKIM) | Backend's authenticated domain |
| Spam | Server-side: honeypot, rate limits, ML + LLM screening — no CAPTCHA |
| Storage & search | Hosted inbox with CSV export |
| Validation | Server-side rules, enforced even with JS off |
| Routing to Slack / Sheets / Notion | Toggles, 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.
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
- Using Next.js? Next.js form handling covers Server Actions vs the endpoint pattern.
- Generate the markup with your fields: contact form generator (React output included).
- Create a free endpoint — 100 submissions/month, no credit card — and your form works in the time it takes to paste a URL.
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
How to Submit a Form with JavaScript fetch()
The vanilla-JS pattern for submitting a form without a page reload — intercept submit, fetch() the data, and render success and per-field errors from the JSON response.
How to Handle Form Submissions in Next.js
Three ways to handle a Next.js form — Server Actions, Route Handlers, or a form backend — with code for each and a clear rule for choosing.
What Is a Form Endpoint? (And How to Use One)
The URL your form's action points to, explained: how endpoints receive submissions, how they relate to form backends, and how to wire one up.