Why a React contact form doesn't need a backend
The instinct, when you need a contact form in a React app, is to reach for a server. Spin up Express, install Nodemailer, store SMTP credentials in a .env, write a /api/contact route, handle CORS, add validation, bolt on spam protection. That's a day of work and a permanently running process — all to forward three fields to your inbox.
None of it is necessary. Here's the thing people miss: the browser already knows how to send data over the network. React runs client-side, fetch() is built in, and a contact form is just "take some fields, POST them somewhere, show a success message." The only piece you genuinely can't do in the browser is the part after the POST — receiving the submission, emailing it to you, and filtering spam. That's exactly what a hosted form endpoint does for you.
So the real architecture is one HTTP request:
- No Express. No server to provision, no Nodemailer, no SMTP credentials to leak, no uptime to babysit.
- No API route. No
/api/contactin Next.js, no dev proxy in Vite. Your component posts directly to the endpoint. - No email plumbing. Deliverability, SPF/DKIM alignment, and retries are handled server-side by the endpoint, not by code you maintain.
- No spam service to wire up. The honeypot and AI classification run on the receiving end.
This works in every React setup — Create React App, Vite, Next.js (client component), Remix, Gatsby, or a plain <script> import. Because there's no server, it deploys anywhere static: GitHub Pages, Cloudflare Pages, S3, Netlify, Vercel. The rest of this guide builds the component.
Step 1: Get a splitforms access key (1 minute)
Before you write any React, grab the key that connects your form to a real backend.
- Open splitforms.com/login in a new tab.
- Enter your email and paste the 6-digit code that lands in your inbox — no password to set.
- The dashboard generates an access key automatically. Copy it. It's a long random string.
- Optional but recommended: under Security, add your production domain (and
localhostfor development) to the Allowed Domains list so nobody else can submit through your key.
That access key is the only credential your form needs. Free tier is 500 submissions/month forever, no card required. If you outgrow it, Pro is $5/month for 5,000 submissions, or $59 for 3 years. Keep the key handy — it goes into a hidden input in the next step.
Step 2: Build the controlled contact component
Here's the full component. It's a controlled form — every input is driven by useState — and it tracks four states: idle, sending, success, and error. On submit it serializes the form with FormData and sends one fetch(). Drop this into a file (for example ContactForm.tsx) and render it anywhere.
import { useState } from "react";
type Status = "idle" | "sending" | "success" | "error";
// The access key is a PUBLIC form ID — safe to hardcode in client code.
const ACCESS_KEY = "YOUR_ACCESS_KEY";
export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState("");
const [values, setValues] = useState({ name: "", email: "", message: "" });
function handleChange(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
setValues((v) => ({ ...v, [e.target.name]: e.target.value }));
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
setError("");
try {
// Controlled inputs keep the DOM in sync, so FormData(e.currentTarget)
// captures exactly what the user typed — plus the hidden fields.
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: new FormData(e.currentTarget),
});
const data = await res.json();
if (res.ok && data.success) {
setStatus("success");
setValues({ name: "", email: "", message: "" });
} else {
setStatus("error");
setError(data.message || "Something went wrong. Please try again.");
}
} catch {
setStatus("error");
setError("Network error — check your connection and try again.");
}
}
if (status === "success") {
return (
<div className="contact-success" role="status">
<h3>Thanks — your message is on its way.</h3>
<p>We reply to every message within one business day.</p>
</div>
);
}
return (
<form className="contact-form" onSubmit={handleSubmit}>
{/* Public form ID — safe in the client bundle. */}
<input type="hidden" name="access_key" value={ACCESS_KEY} />
<label>
Name
<input
type="text"
name="name"
value={values.name}
onChange={handleChange}
autoComplete="name"
required
/>
</label>
<label>
Email
<input
type="email"
name="email"
value={values.email}
onChange={handleChange}
autoComplete="email"
required
/>
</label>
<label>
Message
<textarea
name="message"
value={values.message}
onChange={handleChange}
rows={5}
minLength={10}
required
/>
</label>
{/* Honeypot: hidden from humans, bots fill it. Left uncontrolled. */}
<input
type="checkbox"
name="botcheck"
style={{ display: "none" }}
tabIndex={-1}
autoComplete="off"
/>
{status === "error" && (
<p className="contact-error" role="alert">
{error}
</p>
)}
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send message"}
</button>
</form>
);
}Replace YOUR_ACCESS_KEY with the key from step 1 and this form works immediately. Walking through the important bits:
- Controlled inputs + FormData together. Each field is controlled through
valuesandhandleChange, but on submit we serialize withnew FormData(e.currentTarget)instead of hand-building a JSON object. Because controlled inputs keep the DOM value in sync with state,FormDatareads exactly what the user typed — and it also picks up the hiddenaccess_keyandbotcheckfields for free. You get the ergonomics of controlled state with the brevity of native serialization. - Four explicit states.
statusdrives the whole UI: the button label and disabled state come fromsending, the inline alert fromerror, and the entire form swaps to a thank-you block onsuccess. No separateisLoading/isDonebooleans that can contradict each other. - One fetch, no library. There's no axios, no form library, no SDK. The endpoint returns JSON like
{ success: true }, so we branch onres.ok && data.success. - Reset on success. Clearing
valuesafter a successful send means if you choose to show the form again (instead of the success block), it starts empty.
If you'd rather skip FormData and send JSON, you can — but then you'd have to add the honeypot and access key to the object by hand and set a Content-Type header. FormData is less code and posts as multipart/form-data, which the endpoint expects, so keep it.
Styling the form
The component ships with class names and zero styles so it drops into any design system. Here's a clean, self-contained stylesheet you can paste next to it:
.contact-form {
display: grid;
gap: 16px;
max-width: 460px;
}
.contact-form label {
display: grid;
gap: 6px;
font: 500 14px/1.4 system-ui, sans-serif;
color: #111;
}
.contact-form input,
.contact-form textarea {
padding: 12px 14px;
border: 1px solid #d4d4d4;
border-radius: 10px;
font-size: 16px; /* 16px+ stops iOS Safari from zooming on focus */
font-family: inherit;
background: #fff;
}
.contact-form input:focus,
.contact-form textarea:focus {
outline: 2px solid #ff4f00;
outline-offset: 2px;
border-color: transparent;
}
.contact-form button {
padding: 12px 18px;
border: 0;
border-radius: 10px;
background: #111;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.contact-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.contact-error { color: #c0392b; font-size: 14px; margin: 0; }
.contact-success h3 { margin: 0 0 6px; }If you use Tailwind, drop the stylesheet and put utility classes straight on the JSX instead — className="w-full rounded-lg border px-4 py-3 text-base" and so on. Either way, the one non-negotiable rule is font-size: 16px or larger on inputs. Anything smaller makes mobile Safari zoom the viewport the instant a field is focused, which looks broken. Everything else is taste.
Validation that actually works
You don't need a validation library for a contact form. React controlled inputs plus native HTML5 constraints cover it, and the browser enforces them before your handleSubmit ever runs.
required— the browser blocks submission and focuses the first empty field. This is why the code above doesn't manually check for blanks.type="email"— enforces an email-shaped value and gives mobile users the email keyboard.minLength={10}on the message — kills one-word "hi" spam before it's sent.autoComplete="name"/autoComplete="email"— lets password managers and mobile autofill pre-fill correctly, which measurably lifts completion rates.
Because the inputs are controlled, you can layer richer rules on top whenever you want — for example, disabling the submit button until values.email.includes("@"), or showing a live character count from values.message.length. But start with native validation. It works everywhere, needs no code, and the browser's built-in messages are already localized.
Whatever you do client-side, remember it's only UX. The endpoint validates again server-side — empty fields, malformed emails, and oversized payloads are rejected before they reach your inbox — so a user (or a bot) who bypasses the browser can't push junk through.
Spam protection without reCAPTCHA
The form has a spam trap built in, and it's the single hidden input you might have skimmed past:
<input
type="checkbox"
name="botcheck"
style={{ display: "none" }}
tabIndex={-1}
autoComplete="off"
/>This is a honeypot. A real person never sees it, so they never touch it. Bots, on the other hand, parse the DOM and dutifully fill every field they find — including this one. When the submission arrives with a non-empty botcheck, splitforms silently drops it. No error, no CAPTCHA, no friction for real users.
Three details make it robust:
display: noneas an inline style, not a CSS class — some bots skip elements hidden by a class they can detect, but an inline-hidden field still looks fillable.tabIndex={-1}— keyboard and screen-reader users skip right over it, so it never traps a real person.autoComplete="off"— stops a password manager from "helpfully" filling it and flagging a genuine user as a bot.
Leave the honeypot uncontrolled — no value/onChange. It has no business in your React state; FormData reads it straight from the DOM at submit time.
The honeypot stops roughly 80% of bot spam on its own. For the sophisticated remainder, splitforms adds free AI content classification server-side. Notably, this needs zero extra React code — no <script> tag, no site key, no privacy banner. For why that beats reCAPTCHA on a contact form, see honeypot vs reCAPTCHA and the deeper spam-free React contact form guide.
Where your submissions land
When that fetch() resolves, the submission goes to two places at once:
- Your inbox. Every valid submission is emailed to the address on your splitforms account. You can add CC/BCC recipients for a team, set a
reply-toso hitting Reply answers the sender directly, and enable an autoresponder that thanks the submitter automatically. - Your dashboard. The same submission is stored where you can search, filter, mark as read, export to CSV, or fire it to a webhook (Slack, Discord, a CRM, Zapier). If an email is ever delayed or filtered, the submission is still safe in the dashboard — you never lose a lead to a spam folder.
That dual delivery is the practical reason to use a hosted backend instead of a mailto: link or a one-off SMTP call from an API route: it's durable. To point users to your own thank-you route instead of the default success screen, add a hidden redirect field — or, since this is React, just keep rendering the success block like the component does and navigate with your router.
Troubleshooting
The handful of things that trip people up, in order of how often they come up:
- "Is it safe to put my access key in the React bundle?" Yes. The
access_keyis a public form ID, not a secret — think Stripe publishable key or GA measurement ID. It can only submit to your form; it can't read submissions or touch your account. Anyone can see it in DevTools and that's fine by design. If you want to stop others reusing it, enable Allowed Domains in the dashboard so only your origin is accepted. - Do I need to worry about CORS? No. The endpoint sends
Access-Control-Allow-Originfor cross-origin form posts, so afetch()fromlocalhost:5173or your production domain just works. If you do see a CORS or 401 error, it's almost always the Allowed Domains list excluding your current origin — addlocalhostand your live domain. See the CORS fix guide if it persists. - Don't read the key from an env var inside a memoized handler. Reading
import.meta.env.VITE_ACCESS_KEY(Vite) orprocess.env.NEXT_PUBLIC_ACCESS_KEY(Next.js) is fine — those are inlined at build time. The bug is wrapping the submit handler inuseCallback/useMemowith the wrong dependency array so a stale closure captures an old orundefinedvalue. The component above sidesteps this entirely by holding the key in a module-levelconstand rendering it as a hidden input — no closure, no staleness, one source of truth. - Submission silently does nothing. Open the Network tab and inspect the request to
/api/submit. A 200 withsuccess: falsemeans a validation or honeypot rejection; a 401 means a bad key or a blocked origin. Logdatain thecatch/elsebranch while debugging. - Email never arrives but the dashboard shows it. That confirms the POST worked — it's a deliverability issue, not a code issue. Check spam first, then your notification email under account settings. Full flow in contact form not working.
- Double submit on mobile. Already handled — the button is
disabledwhilestatus === "sending", so a double-tap can't fire two requests.
Next steps
- Copy-paste, framework-specific setup lives in the contact form for React guide — including a React Hook Form variant.
- Building with the App Router? See contact form for Next.js and add a contact form to Next.js without an API route.
- On a Vite SPA specifically? The Vite + React contact form walkthrough covers env vars and static deploys.
- Comparing hosted backends? Read the top 10 React form backends.
- The framework integration reference is at the React form backend docs.
- Get your access key: splitforms.com/login.
FAQ
Do I need a backend to handle a React contact form?
No. React runs in the browser, and the browser can already POST form data to any HTTPS endpoint. A contact form has one job — take a few fields and deliver them to your inbox — and that job doesn't need a server you own. You point a fetch() call at a hosted form endpoint like splitforms.com/api/submit, and it handles storage, email delivery, and spam filtering. You ship a single component with zero backend code, zero server to deploy, and zero SMTP configuration.
Do I need an Express server or a React/Next.js API route?
No to both. An Express server means provisioning a host, wiring up Nodemailer, storing SMTP credentials, and keeping a process alive 24/7 just to forward three fields. A Next.js API route (or a Vite proxy) still needs a running server and still leaves you to solve email delivery and spam yourself. A hosted endpoint removes the entire middle layer — your React component posts straight to it. This is the single biggest reason people over-build contact forms: they assume the form needs custom server code. It doesn't.
Is the access_key safe to expose in client-side React code?
Yes. The access_key is a public form identifier, not a secret credential — the same way a Stripe publishable key or a Google Analytics ID is meant to live in client code. It only lets someone submit to your form; it can't read your submissions, change your settings, or access your account. Anyone can view it in your bundle or network tab and that's fine by design. If you're worried about someone spamming your endpoint with your key, turn on Allowed Domains in the dashboard so submissions are only accepted from your own origin.
Where do the submissions actually land?
Two places at once. Every valid submission is emailed to the address on your splitforms account (you can add CC/BCC recipients and an autoresponder), and it's stored in your dashboard where you can search, filter, export to CSV, or forward it to a webhook. So even if an email is delayed or lands in spam, the submission is never lost — it's always in the dashboard. That's the practical difference between a hosted form backend and a raw mailto: link or a fire-and-forget SMTP call.
Does a honeypot really stop spam without reCAPTCHA?
For a normal contact form, yes — a hidden honeypot field catches roughly 80% of bot spam because bots fill every input they find, and a human never sees the hidden one. splitforms drops any submission where the botcheck field is non-empty, automatically. For the remaining sophisticated spam, splitforms layers free AI content classification on top. Adding reCAPTCHA to a React form hurts conversion, adds a third-party script and a privacy banner, and blocks keyboard-only users — for a contact form it's rarely worth it.