How to build a contact form in React

You have a Vite React app. You need a contact form. The first instinct is to reach for something like Express or a serverless function to handle the POST. But this isn't Next.js. There are no API routes baked in, and building your own backend just for a contact form is more infrastructure than the problem warrants.
There's a cleaner path. Let me show you.
If you're using Next.js specifically, see How to build a contact form in Next.js instead. That post covers Route Handlers, Server Actions, and Next.js-specific patterns.
The options, briefly
When building a contact form in a plain React app, you have three realistic paths:
- Your own API: an Express server, a Netlify function, a Vercel serverless route. You own the backend code, the email sending, the spam filtering, and the storage.
- A React form library: handles client-side state and validation, but you still need somewhere to send the data.
- A form backend service: you point your form at a hosted endpoint. Submissions are stored, spam-filtered, and forwarded to you. No backend to write or maintain.
For most React apps on static hosting (Vercel, Netlify, GitHub Pages, Cloudflare Pages), option 3 is the right call. This guide covers that approach using Formtorch, though the fetch pattern works with any form backend service.
The simplest version: plain HTML
If your contact section is on a mostly static page and you don't care about loading or success states, a plain HTML form works fine in a React component. You don't need JavaScript to handle the submission:
// components/ContactForm.tsx
export function ContactForm() {
return (
<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
<input name="name" type="text" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit">Send message</button>
</form>
);
}The browser handles the submission natively. Formtorch redirects to a default thank-you page after. If you want to redirect to your own page:
<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
<input
type="hidden"
name="_redirect"
value="https://yoursite.com/thank-you"
/>
{/* your fields */}
</form>For a lot of use cases, this is enough. But React apps usually want something more interactive, with loading and success states built in.
A real React contact form
Here's a complete component with loading, success, and error states. No "use client" directive needed here — that's a Next.js concept. In a plain React app, every component is a client component by default.
// components/ContactForm.tsx
import { useState } from "react";
type FormState = "idle" | "loading" | "success" | "error";
export function ContactForm() {
const [state, setState] = useState<FormState>("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setState("loading");
try {
const data = new FormData(e.currentTarget);
const res = await fetch("https://formtorch.com/f/YOUR_FORM_ID", {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: data,
});
if (!res.ok) throw new Error("Submission failed");
setState("success");
} catch {
setState("error");
}
}
if (state === "success") {
return (
<div>
<h3>Message sent.</h3>
<p>Thanks for reaching out. I'll get back to you soon.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required />
</div>
{state === "error" && <p>Something went wrong. Please try again.</p>}
<button type="submit" disabled={state === "loading"}>
{state === "loading" ? "Sending…" : "Send message"}
</button>
</form>
);
}A few things worth noting:
X-Requested-With: XMLHttpRequest: this header tells Formtorch to respond with JSON instead of an HTML redirect. Without it, a 200 response still works, but you'd get HTML back which isn't useful in a fetch handler.
FormData over JSON: FormData works with any input type including file uploads, and you don't need to manually list every field. Add or rename a field later and the submission still works without touching this code.
disabled={state === "loading"}: prevents double-submissions when someone clicks twice.
Keeping the endpoint out of your source code
Hardcoding a Formtorch URL in your component is fine for a quick prototype, but if you're committing this to git, you probably don't want a public form ID embedded in your source. Vite makes this easy with environment variables.
Create a .env file at your project root:
VITE_FORM_ENDPOINT=https://formtorch.com/f/abc123Then reference it in your component:
const res = await fetch(import.meta.env.VITE_FORM_ENDPOINT, {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: data,
});A few things to know about Vite env variables:
- Variables must be prefixed with
VITE_to be exposed to your frontend bundle. Others are server-only and will beundefinedat runtime. - You access them via
import.meta.env.VITE_*, notprocess.env.*. This is different from Next.js (NEXT_PUBLIC_*andprocess.env.NEXT_PUBLIC_*). - Add
.envto your.gitignoreand commit a.env.examplewith the variable name but not the value.
If you're deploying to Vercel or Netlify, add VITE_FORM_ENDPOINT to your
environment variable settings in the hosting dashboard. The build process
picks it up there.
Adding client-side validation
The form above relies on the browser's built-in required validation. If you want to control the error presentation yourself, here's how to layer in a custom validate() function:
// components/ContactForm.tsx
import { useState } from "react";
type FormState = "idle" | "loading" | "success" | "error";
type Errors = Partial<Record<"name" | "email" | "message", string>>;
function validate(data: FormData): Errors {
const errors: Errors = {};
if (!data.get("name")) errors.name = "Name is required.";
const email = String(data.get("email") ?? "");
if (!email) errors.email = "Email is required.";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errors.email = "Enter a valid email address.";
if (!data.get("message")) errors.message = "Message is required.";
return errors;
}
export function ContactForm() {
const [state, setState] = useState<FormState>("idle");
const [errors, setErrors] = useState<Errors>({});
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const errs = validate(data);
if (Object.keys(errs).length > 0) {
setErrors(errs);
return;
}
setErrors({});
setState("loading");
try {
const res = await fetch(import.meta.env.VITE_FORM_ENDPOINT, {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: data,
});
if (!res.ok) throw new Error();
setState("success");
} catch {
setState("error");
}
}
if (state === "success") {
return <p>Thanks for reaching out. I'll get back to you soon.</p>;
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" />
{errors.name && <span>{errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
{errors.email && <span>{errors.email}</span>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} />
{errors.message && <span>{errors.message}</span>}
</div>
{state === "error" && <p>Something went wrong. Please try again.</p>}
<button type="submit" disabled={state === "loading"}>
{state === "loading" ? "Sending…" : "Send message"}
</button>
</form>
);
}The noValidate attribute on the form disables the browser's built-in validation UI so you control how and where errors appear.
Using react-hook-form
For more complex forms with dynamic fields, conditional sections, or multi-step flows, react-hook-form integrates cleanly with the same fetch pattern:
// components/ContactForm.tsx
import { useForm } from "react-hook-form";
type Fields = {
name: string;
email: string;
message: string;
};
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<Fields>();
async function onSubmit(data: Fields) {
const formData = new FormData();
Object.entries(data).forEach(([k, v]) => formData.append(k, v));
const res = await fetch(import.meta.env.VITE_FORM_ENDPOINT, {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: formData,
});
if (!res.ok) throw new Error("Submission failed");
}
if (isSubmitSuccessful) {
return <p>Message sent. Talk soon.</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: "Name is required" })} />
{errors.name && <span>{errors.name.message}</span>}
<input
type="email"
{...register("email", { required: "Email is required" })}
/>
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register("message", { required: "Message is required" })} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending…" : "Send message"}
</button>
</form>
);
}When react-hook-form throws from onSubmit to signal a server error,
isSubmitSuccessful stays false. You can check formState.submitCount to
detect repeated failures and show a fallback message.
Where to get your form ID
You'll need a Formtorch account and a form created in the dashboard. The endpoint appears immediately when you create the form.
Create a project
Sign in at formtorch.com and click New Project. Projects are containers for your forms.
Create a form
Inside your project, click New Form. Give it a name. The endpoint URL appears immediately.
Copy the endpoint
Your endpoint looks like https://formtorch.com/f/abc123. Set this as
VITE_FORM_ENDPOINT in your .env file.
Set up notification emails
Go to Form Settings → Notifications and add your email. Confirm it, and you'll get an email for each new submission.
What about spam?
Formtorch runs TorchWarden on every submission. It checks for common spam signals (bots filling hidden fields, rapid-fire identical submissions, keyword patterns) without adding friction for real users. You don't configure anything; it's on by default.
If you're building a custom backend instead, a honeypot field is the most practical starting point: a hidden input that real users never fill, but bots usually do.
{
/* Add inside your form. Hide with CSS, not type="hidden" */
}
<input
name="_honeypot"
tabIndex={-1}
autoComplete="off"
style={{ display: "none" }}
/>;For a full breakdown of what makes forms attractive to bots and which defenses hold up, see How to prevent spam in contact forms.
Wrapping up
The short version:
- For a simple static page: a plain HTML form pointing at a Formtorch endpoint
- For a React app:
fetchwithFormDataand a"loading" | "success" | "error"state - For complex forms:
react-hook-formwraps around the same fetch pattern - For the endpoint URL: a Vite env variable (
import.meta.env.VITE_FORM_ENDPOINT) keeps it out of your source code
You don't need a backend to have a working, spam-filtered contact form with email notifications. If you're curious about what that backend would involve if you built it yourself, The hidden complexity of form handling walks through what's actually under the hood.
Need a form endpoint?
Create a free Formtorch account and get your endpoint URL in under two minutes.

