React is a frontend library. It renders UI beautifully — but it has no built-in way to store form submissions, send emails, or connect to a database. You need a backend for that.
The catch: most developers building marketing sites, portfolios, or SaaS landing pages do not want to deploy and maintain a backend just for a contact form. Here is how to handle React form submissions without one.
The Standard (Wrong) Approach
Most tutorials tell you to spin up Express, add a /api/contact endpoint, configure nodemailer, deploy to Railway or Heroku, and manage secrets. That works — but it costs time, money, and ongoing maintenance for something that should take two minutes.
The Simpler Approach: external form endpoint
Flowqen gives you a POST endpoint that receives your form data, stores it, and sends notifications. No backend code needed on your side.
// ContactForm.tsx
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("loading");
try {
const res = await fetch("https://flowqen.com/api/f/YOUR_FORM_ID", {
method: "POST",
body: new FormData(e.currentTarget),
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error("Submission failed");
setStatus("success");
} catch {
setStatus("error");
}
}
if (status === "success") {
return <p>Message sent! We'll reply within 24 hours.</p>;
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input name="name" type="text" required />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Message
<textarea name="message" required />
</label>
{/* Honeypot — hides from users, traps bots */}
<input name="_gotcha" type="text" style={{ display: "none" }} />
{status === "error" && <p>Something went wrong. Try again.</p>}
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "Sending…" : "Send message"}</button>
</form>
);
}
With React Hook Form (Better UX)
If you are already using React Hook Form for validation, you can call Flowqen from the onSubmit handler:
import { useForm } from "react-hook-form";
type Inputs = { name: string; email: string; message: string };
export default function ContactForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Inputs>();
const [sent, setSent] = React.useState(false);
const onSubmit = async (data: Inputs) => {
const fd = new FormData();
Object.entries(data).forEach(([k, v]) => fd.append(k, v));
await fetch("https://flowqen.com/api/f/YOUR_FORM_ID", {
method: "POST",
body: fd,
headers: { Accept: "application/json" },
});
setSent(true);
};
if (sent) return <p>Sent!</p>;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: true })} placeholder="Name" />
{errors.name && <span>Required</span>}
<input {...register("email", { required: true })} type="email" />
<textarea {...register("message", { required: true })} />
<button type="submit">Send</button>
</form>
);
}
Handling File Uploads
Flowqen supports file uploads out of the box. Add an encType attribute and a file input:
<form
onSubmit={handleSubmit}
encType="multipart/form-data"
>
<input name="attachment" type="file" />
{/* ...other fields */}
</form>
What Gets Handled For You
- Submission storage — searchable dashboard with export
- Email notifications with field-level formatting
- Spam filtering — honeypot + server-side rate limiting
- Auto-reply emails to the submitter
- Slack / Discord / Notion / Google Sheets sync
- Webhook forwarding to your own endpoints
FAQ
Is it safe to expose the form ID in the frontend?
Yes. The form ID controls only which form receives data, not any credentials. You can restrict allowed domains per form in the dashboard.
Does this work with Vite, Create React App, and other bundlers?
Yes. It is a fetch call — it works in any JavaScript environment.
Can I use JSON instead of FormData?
Yes. Send Content-Type: application/json with a JSON body and Flowqen will parse it correctly.
Or skip all this and use FlowQen — 2 lines of code, done.