Build a copy-pasteable React contact form that posts straight to Formigo - no backend
of your own, no server route, no fuss. Just useState, fetch,
and a form endpoint that actually answers.
Every Formigo form has its own endpoint that looks like
https://formigo.io/f/your-form. Your React app posts a plain JSON body
to that URL - Formigo stores the submission, emails you, and replies with a tidy
2xx. The endpoint is CORS-enabled, so a client-side
fetch() from the browser works out of the box. No proxy route required.
Swap your-form for your real form slug from the Formigo dashboard.
Everything else below is ready to paste.
Function component, hooks, success / error / rate-limit handling
Here is the whole thing - drop it into a .jsx file and render
<ContactForm /> wherever you need it. It handles a successful send,
a generic error, and Formigo's 429 rate-limit response, plus two quiet
spam fields. We break down each piece right after.
import { useState, useEffect } from "react";
const FORM_ENDPOINT = "https://formigo.io/f/your-form";
export default function ContactForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
_formigo_hp: "", // Honeypot: real humans leave this empty
});
const [status, setStatus] = useState("idle"); // idle | sending | success | error
const [errorMsg, setErrorMsg] = useState("");
const [loadedAt, setLoadedAt] = useState(0);
// Stamp the moment the form mounted. Bots tend to submit instantly.
useEffect(() => {
setLoadedAt(Math.floor(Date.now() / 1000));
}, []);
const handleChange = (e) => {
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setStatus("sending");
setErrorMsg("");
try {
const response = await fetch(FORM_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
_formigo_t: loadedAt, // Unix timestamp from when the form mounted
}),
});
if (response.ok) {
setStatus("success");
setFormData({ name: "", email: "", message: "", _formigo_hp: "" });
setLoadedAt(Math.floor(Date.now() / 1000));
} else if (response.status === 429) {
const data = await response.json();
setStatus("error");
setErrorMsg(`Whoa there - too many tries. Retry in ${data.retry_after}s.`);
} else {
setStatus("error");
setErrorMsg("Something went sideways. Please try again.");
}
} catch (err) {
setStatus("error");
setErrorMsg("Network hiccup. Check your connection and retry.");
}
};
return (
<form onSubmit={handleSubmit} className="contact-form">
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
required
/>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
required
/>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
rows={5}
value={formData.message}
onChange={handleChange}
required
/>
{/* Honeypot: hidden from real users, catnip for bots */}
<input
type="text"
name="_formigo_hp"
value={formData._formigo_hp}
onChange={handleChange}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ position: "absolute", left: "-9999px" }}
/>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending..." : "Send message"}
</button>
{status === "success" && (
<p role="status" className="form-ok">
Thanks! Your message landed safely.
</p>
)}
{status === "error" && (
<p role="alert" className="form-error">
{errorMsg}
</p>
)}
</form>
);
}
Nothing magic here - just idiomatic modern React. Here is what each part is doing.
useState
One formData object holds your fields as a controlled form, and a small
status string (idle → sending →
success / error) drives the button label and the messages.
Keeping status as a single value beats juggling three booleans.
useEffect
On mount we record the current Unix time in loadedAt and send it as
_formigo_t. Bots usually post the instant a page loads; a human takes a
few seconds to type, so the gap is a handy spam signal.
fetchconst response = await fetch("https://formigo.io/f/your-form", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...formData, _formigo_t: loadedAt }),
});
We send JSON, so we set Content-Type: application/json. (Formigo also
accepts application/x-www-form-urlencoded if you would rather post a
FormData body - either is fine.) Because the endpoint is CORS-enabled,
this runs directly from the browser.
A successful submission is any 2xx, so response.ok is your
green light - we show a thank-you and reset the fields. If you hit the rate limiter
you get an HTTP 429 with a JSON body like
{ "retry_after": 30 }, so we read retry_after and tell the
user exactly how long to wait. Anything else falls through to a friendly generic error,
and a network failure is caught by the surrounding try / catch.
_formigo_hp is a real input pushed off-screen with
position: absolute; left: -9999px and marked aria-hidden with
tabIndex={-1}. People never see or focus it, so it stays empty. Bots that
blindly fill every field give themselves away - a non-empty honeypot is a strong spam
signal. Together with the timestamp, that is most of your spam gone without a single
CAPTCHA.
Using Next.js? This same component works in the App Router as a Client Component -
just add "use client" at the top of the file.
Create a free Formigo form, paste your formigo.io/f/
endpoint into the component above, and start collecting submissions today. No backend to
build, no spam to wade through.