React
Untested: Not yet tested on a live site. The steps follow how the form address works today; if one differs, tell us.
A React contact form for a single-page app, such as one built with Vite. It loads the bot check, sends JSON with fetch and shows the answer.
The component
Paste this component and render it where the form goes. For Next.js, use the Next.js guide.
Example form address. Use yours from the dashboard.
ContactForm.jsx
import { useEffect, useState } from "react";
const ENDPOINT = "https://f.formparcel.live/f/f_k7QmXp2aVb9sTn4cLw8RhE";
// FormParcel's bot check: no cookies, no widget. Without it, submissions wait in your Spam folder.
const BOT_CHECK_SCRIPT = "https://f.formparcel.live/f/formparcel.js";
export function ContactForm() {
const [status, setStatus] = useState("idle");
useEffect(() => {
if (document.querySelector('script[src="' + BOT_CHECK_SCRIPT + '"]')) return;
const script = document.createElement("script");
script.src = BOT_CHECK_SCRIPT;
script.async = true;
document.head.append(script);
}, []);
async function onSubmit(event) {
event.preventDefault();
setStatus("sending");
const data = Object.fromEntries(new FormData(event.currentTarget));
try {
// One proof per submission. The bot check may still be loading on a slow phone, so wait for it (10 seconds at most). If it cannot load, the lead is still sent and waits in your Spam folder.
const fp = window.FormParcel || await new Promise((ok) => { addEventListener("formparcel:ready", () => ok(window.FormParcel), { once: true }); setTimeout(ok, 10000); });
const _fp_proof = await fp?.proof(ENDPOINT).catch(() => undefined);
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ ...data, _fp_proof }),
});
// 204: the post was dropped as spam (the trap field was filled). Nothing was saved.
const result = response.status === 204 ? null : await response.json();
// "pending" is success too: the lead is stored. Never send it again.
setStatus(result?.accepted ? "sent" : "error");
} catch {
setStatus("error");
}
}
if (status === "sent") return <p>Thanks, we got your message.</p>;
return (
<form onSubmit={onSubmit}>
{/* Spam trap: keep it hidden from people. */}
<div aria-hidden="true" style={{ position: "absolute", left: "-10000px" }}>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
</div>
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send"}
</button>
{status === "error" ? <p role="alert">Your message was not sent. Please try again.</p> : null}
</form>
);
}- A
200withaccepted: trueis success, whetherdeliveryStatusis"delivered"or"pending". Never send a pending lead again. - A
204means the hidden trap field was filled: nothing was saved. - Every refusal and its code is on Responses and errors.
Bot check. A lead sent without FormParcel's bot check is saved but held in your Spam folder, not delivered. That is every post from a server, a webhook or
curl, and every post from a page that does not load the script. The dashboard then shows Bot check not installed under Needs action. Fix: load the script on the page and send the proof from FormParcel.proof() with each fetch. Learn moreCheck it
Send a test lead from the dashboard, then submit the form once from the deployed site and open its receipt under Leads.
Something wrong on this page? Tell us