splitforms.com
REACT · CONTACT FORM

Contact form for React websites

Plug-and-play contact form for any React app. Vite, Create React App, Remix, Gatsby — all work. One fetch call, full state handling.

1,000 free submissions every month.·No credit card.
contact.jsxjsx34 lines
01import { useState } from "react";
02
03export default function ContactForm() {
04 const [status, setStatus] = useState("idle");
05
06 async function onSubmit(e) {
07 e.preventDefault();
08 setStatus("loading");
09 const formData = new FormData(e.target);
10 formData.append("access_key", "YOUR_ACCESS_KEY");
11
12 const res = await fetch("https://splitforms.com/api/submit", {
13 method: "POST",
14 body: formData,
15 });
16 const json = await res.json();
17 setStatus(json.success ? "ok" : "err");
18 if (json.success) e.target.reset();
19 }
20
21 return (
22 <form onSubmit={onSubmit}>
23 <input type="text" name="name" placeholder="Name" required />
24 <input type="email" name="email" placeholder="Email" required />
25 <textarea name="message" placeholder="Message" required />
26 <input type="checkbox" name="botcheck" style={{ display: "none" }} tabIndex={-1} />
27 <button type="submit" disabled={status === "loading"}>
28 {status === "loading" ? "Sending..." : "Send"}
29 </button>
30 {status === "ok" && <p>Thanks!</p>}
31 {status === "err" && <p>Error sending. Please try again.</p>}
32 </form>
33 );
34}
1,000
submissions / mo, free
14ms
median latency, edge
0
lines of backend code
17+
frameworks supported
✶ Live preview

What your React contact form actually looks like.

Drop-in form backend with spam filtering, signed webhooks, and a real submissions dashboard. The same code in this preview is what you copy into your React project — no SDK, no plugin, no PHP.

  • 1,000 submissions per month, free forever
  • Honeypot + AI spam classifier on every plan
  • Signed webhooks to Slack, Discord, your server
React contact form on Splitforms — drop-in form backend with spam filtering and webhooks
§ 01Setup3 steps · 60 seconds · zero config

Ship a React contact form without a backend.

No SDK, no PHP, no plugin. Your form posts standard FormData to one URL — submissions land in your inbox.

STEP 01GENERATE

Get your free access key

Verify your email and your access key is generated instantly. Free for 1,000 submissions per month, forever.

Create your form

By signing up, you agree to our terms and privacy policy.

STEP 02EMBED

Drop in the React code

Copy the React snippet on the right and paste it into your project. Replace YOUR_ACCESS_KEY with the key from step 1.

snippetjsx
import { useState } from "react";
…
STEP 03RECEIVE

Submissions land in your inbox

Hits your dashboard and email in seconds. Forward to Slack, Discord, Sheets, Notion, or any signed webhook URL.

inbox · 1 newjust now
FROM contact@yoursite.com
New React form submission
Maya Iyer maya@studio71.co
Loved the new pricing page — quick question about the 4-year plan. Are usage limits per project or account-wide?
§ 02Live demosandboxed · no key required · no submission sent

Try it now — no signup, no key.

This is a styled HTML preview of what your React form will look like. Submitting opens a confirmation, no real request is sent.

preview · reactlocalhost:3000
✦ what just happened

Your React form posts FormData to /api/submit. Splitforms validates the access key, runs the spam classifier, and forwards the parsed submission to your inbox plus the dashboard.

  • 14ms median round-trip from the edge.
  • Honeypot + classifier, no CAPTCHA.
  • Per-domain key locking out of the box.
REQUEST · POST /api/submit
{
  "access_key": "sk_live_4f9a_••••",
  "name":       "Maya Iyer",
  "email":      "maya@studio71.co",
  "message":    "…"
}
← 200 OK · { "success": true } · 14ms
§ 03Best practices5 rules · production-tested

How to ship this without regrets.

Five rules that make the difference between a form that works in the demo and a form that survives launch traffic.

  1. 01

    Use a single `status` state variable: `'idle' | 'loading' | 'ok' | 'err'`. Cleaner than 4 booleans.

  2. 02

    Show inline success/error messages instead of alert() — much better UX, easier to test, accessible.

  3. 03

    After successful submit, call `e.target.reset()` to clear the form. Users expect this; not doing it feels broken.

  4. 04

    Add `aria-busy={status === 'loading'}` to the form element for screen readers.

  5. 05

    If using a UI library (MUI, Chakra, Radix), use their Form/Input components for accessibility — splitforms doesn't care about your DOM as long as inputs have `name` attributes.

§ 04Common gotchas in React6 edge cases worth knowing

What bites people who skip the docs.

Worth a 60-second skim before you ship to production. Each one has caused a React support ticket at least once.

⚠ gotcha

useState + setStatus inside async causes race conditions

If a user double-clicks submit before the first request settles, you'll fire two POSTs. Always disable the button while status === 'loading'. Better still, also use an AbortController to cancel the in-flight request if a re-submit happens.

⚠ gotcha

FormData and controlled inputs can desync

If you control inputs via useState (value={name} onChange={...}), the FormData object you build with new FormData(e.target) won't see your state — it reads the actual DOM. Either use uncontrolled inputs (no value prop) or build the body manually from state.

⚠ gotcha

CSP errors when posting to a third-party endpoint

If your site has a strict Content Security Policy with connect-src 'self', fetch to splitforms.com will be blocked. Add splitforms.com to your connect-src directive: connect-src 'self' https://splitforms.com.

⚠ gotcha

Strict mode + double-mount triggers two submissions in dev

React 18+ strict mode mounts components twice in development to surface side effects. If you put your fetch call in useEffect (don't), you'll see double-submits. Always trigger network calls in event handlers, not effects.

⚠ gotcha

Don't forget to e.preventDefault() on the form's onSubmit

Without preventDefault, the browser does its own form submission to wherever the form's action attribute points (or the current page) AND your handler runs — you get an unexpected page reload + your fetch call.

⚠ gotcha

Stale closures in event handlers freeze your access key on hot reload

If you read process.env.REACT_APP_SPLITFORMS_KEY (CRA) or import.meta.env.VITE_SPLITFORMS_KEY (Vite) inside a useCallback or memoized handler, rotating the key in .env and saving doesn't update the captured value during HMR — only a full page refresh does. The form keeps POSTing the old key for the rest of the dev session and silently 401s. Read env vars at the module top level, or accept the key as a prop so React's normal reactivity propagates the new value.

§ 04bNative React forms…and where they break down

How React handles forms without splitforms.

The shape of the problem before splitforms enters the picture — and the gap it fills for React specifically.

React itself ships nothing for form submission — it's a view layer. The historical baseline is one of: an Express/Hono/Fastify server you stand up just for POST /api/contact, a Function-as-a-Service (Vercel/Netlify/Cloudflare) that ends up needing the same SMTP wiring, or a third-party form library (React Hook Form, Formik) that handles validation but still leaves you to operate the backend. Vite, CRA, and Remix all default to assuming you have somewhere to POST — they just don't tell you where. Splitforms is the where: a single fetch call, no library install, no useEffect gymnastics, no Express boilerplate.

§ 04cAlternative integration patterns2 ways to wire it

Two ways to ship splitforms on React.

Pick the pattern that matches your constraints — JS budget, key-exposure tolerance, server-side opacity. Both produce the same result.

PATTERN A

Pattern A — uncontrolled inputs + native FormData

Skip useState per field. Inputs stay uncontrolled, new FormData(e.currentTarget) reads them at submit time, status state covers idle/loading/ok/err. ~25 lines, no form library.

pattern-a.jsxjsx17 lines
01import { useState } from "react";
02export default function ContactForm() {
03 const [status, setStatus] = useState("idle");
04 return (
05 <form onSubmit={async (e) => {
06 e.preventDefault(); setStatus("loading");
07 const fd = new FormData(e.currentTarget);
08 fd.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
09 const r = await fetch("https://splitforms.com/api/submit", { method: "POST", body: fd });
10 setStatus((await r.json()).success ? "ok" : "err");
11 }}>
12 <input name="email" type="email" required />
13 <textarea name="message" required />
14 <button disabled={status === "loading"}>Send</button>
15 </form>
16 );
17}
PATTERN B

Pattern B — React Hook Form for validation, splitforms for delivery

RHF handles client-side validation (zod schema, error messages); on valid submit, hand off to splitforms. RHF's handleSubmit callback receives parsed values — repackage as FormData and POST.

pattern-b.jsxjsx8 lines
01import { useForm } from "react-hook-form";
02const { register, handleSubmit, formState } = useForm();
03const onSubmit = async (data) => {
04 const fd = new FormData();
05 Object.entries(data).forEach(([k, v]) => fd.append(k, v));
06 fd.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
07 await fetch("https://splitforms.com/api/submit", { method: "POST", body: fd });
08};
§ 04dDeployment notes for Reacthosting · env vars · CSP

Shipping React + splitforms to production.

Host-specific gotchas, env-var conventions, and the boring-but-load-bearing details for putting this on the public internet.

Vite-built React apps are static — they deploy to any static host (Vercel, Netlify, Cloudflare Pages, S3, GitHub Pages). The splitforms fetch is cross-origin, so configure your CSP to allow connect-src 'self' https://splitforms.com if you have one. Vite reads env vars from .env at build time and only exposes those prefixed VITE_ to the browser bundle. CRA uses REACT_APP_ instead. On Cloudflare Pages, the build output is served from the edge with sub-50ms cold starts; splitforms adds another ~30ms RTT — not noticeable in practice. Lock the access key to your live origin in the splitforms dashboard.

§ 05Comparisonvs native react

splitforms vs native react.

What you get for free vs what you build, pay for, or do without.

FeatureNative Reactsplitforms
Setup timeExpress/Hono backend + email service (1 day)60 seconds
Hosting costAlways-on server ($5+/mo)Static-only (free)
Spam filteringRoll your own or pay reCAPTCHA EnterpriseIncluded free
Submissions storageDatabase setup + authDashboard included
TypeScript typesGenerate from your schemaFormData is native; no extra types
Cold start0-3s on serverless<200ms (CDN-edge)
§ 07Questions6 answered

Things developers ask before they integrate.

Direct answers, no marketing fluff. Missing one? Email hello@splitforms.com.

01Does Splitforms work with Vite, Create React App, and Remix?
Yes — all three. The component code is identical. Vite uses import.meta.env.VITE_SPLITFORMS_KEY, CRA uses process.env.REACT_APP_SPLITFORMS_KEY, Remix uses process.env.SPLITFORMS_KEY (server-side via loader/action).
02How do I handle CSRF in Splitforms?
Splitforms doesn't require a CSRF token because each access key has built-in domain locking (in dashboard → Settings → Allowed domains). Submissions from any other origin are rejected. That's CSRF protection without the token plumbing.
03Can I use React Hook Form or Formik?
Yes — they're orthogonal. Use them for client-side validation, then on submit either pass the validated data through fetch to splitforms.com/api/submit, or have them call e.target.requestSubmit() to use the native form path. Splitforms only cares about field name → value pairs in the body.
04What happens if the user refreshes after submitting?
Nothing — the request already went through. If you want to prevent the back-button from re-submitting, redirect to a /thanks page after success (using react-router or window.location).
05How do I test the form locally?
Splitforms accepts requests from any origin during development if your access key has no domain restrictions. Add localhost to allowed domains for production-mode testing, or skip restrictions for the dev key.
06Can I use this with React Server Components?
RSC can't have onSubmit handlers — they're server-rendered. Either use the server-action pattern (supported in Next.js / experimental in Remix) or wrap the form in a Client Component ("use client").
✻ ✻ ✻

Ship your React contact form in 60 seconds.

1,000 free submissions per month. No credit card. Lock the access key to your domains, paste the snippet, watch submissions land in your inbox.

Get free access key →Read the docs
v0.1 · founders pricing locked in · early access open