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={{ display: "none" }}
/>
<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 hidden with display: none 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.
Common questions about building a React contact form without a backend.
No. The component posts directly to your Formigo endpoint from the browser, so a static React build deployed to any host works. The endpoint is CORS-enabled, so you do not need a proxy or a server of your own.
Yes. The component is plain React with useState and useEffect and no build-tool-specific code, so it works in any React setup that runs in the browser. Next.js has its own guide because the App Router adds Server Actions and Route Handlers as alternatives.
The timestamp records when the form was rendered, not when it was sent. Formigo compares it against the time the submission arrives, and anything under 2 seconds looks automated. Setting it at submit time would always produce a zero-second gap and defeat the check.
Yes, keep it in the same state object as the other fields so its value is submitted with them and reset after a successful send. It must arrive empty: a filled honeypot adds 100 spam points, which is enough to flag the submission on its own. Hide it with display: none and add tabIndex={-1} and aria-hidden so nobody can tab into it.
Check for response.status === 429 before treating the response as an error. Formigo allows 5 submissions per IP per form every 10 minutes and 20 per IP per hour across all forms, and a throttled response carries a JSON body with retry_after, the seconds until the limit resets. Show that number in your error state.
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.