Every React contact-form tutorial ranks by selling a service (EmailJS, Formspree) or by walking you through a full-stack build with Node and Nodemailer that is disproportionate to a form that gets three submissions a week. mailnix is the middle path. A publishable form ID in the URL, an origin allowlist as the safety net, and a real inbox behind it with spam scoring, status workflow, and per-submission replies. Works with plain useState + fetch, or with react-hook-form + Zod if you already use those for validation.
src/ContactForm.jsx
import { useState } from "react";
// Replace with the public_id from the mailnix dashboard. Not a secret.
const FORM_ID = "PUBLIC_ID";
const ENDPOINT = `https://api.mailnix.ch/v1/public/forms/${FORM_ID}`;
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
async function onSubmit(e) {
e.preventDefault();
setStatus("sending");
setError(null);
const def = await fetch(ENDPOINT).then((r) => r.json());
const data = Object.fromEntries(new FormData(e.currentTarget));
try {
const res = await fetch(`${ENDPOINT}/submissions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: { ...data, _mnx_ft: def.fill_time_token },
}),
});
if (res.status !== 201) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || "Submission failed");
}
setStatus("sent");
e.target.reset();
} catch (err) {
setStatus("error");
setError(err.message);
}
}
if (status === "sent") {
return <p>Thanks. We got your message and will be in touch.</p>;
}
return (
<form onSubmit={onSubmit} noValidate>
<label>
Name
<input name="name" required autoComplete="name" />
</label>
<label>
Email
<input name="email" type="email" required autoComplete="email" />
</label>
<label>
Message
<textarea name="message" required rows={5} />
</label>
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending..." : "Send message"}
</button>
{status === "error" && <p role="alert">Something went wrong: {error}</p>}
</form>
);
}Setup, in order
EmailJS and Formspree forward the fields and stop. mailnix Forms keeps every submission in an inbox with spam scoring, status workflow, CSV export, retention window, and one-click replies from the dashboard.
The public_id is visible in your bundle by design. The security boundary is the Origin allowlist plus per-form rate caps, so a copy of the id on another domain gets rejected server-side.
The JSON body shape is the only contract. Use react-hook-form for validation and let mailnix Forms enforce the schema server-side; both stay in sync because both are generated from the field list you designed once.
The same form is reachable from Claude, ChatGPT, and Cursor over MCP. form_reply_send lets an assistant answer a submission from the chat, always to the captured address.
No. A public form endpoint posts directly from the browser to api.mailnix.ch. The origin allowlist and per-form rate caps are what a backend would otherwise give you.
It is not. It is designed to appear in your React bundle and page source. Only tokens starting with mnx_live_ are secrets and must stay on a server.
Yes. react-hook-form handles validation and gives you a values object; call fetch to the mailnix submit endpoint from its onSubmit. mailnix does not care which library assembled the payload.
Add a verified owner notify address to the form in the mailnix dashboard. Every submission emails that address, and the raw submission is also visible in the inbox with a spam score.
Every submission is scored on ingest and low-quality entries land in a separate Spam view. Add the honeypot input at any time, or turn on Turnstile or hCaptcha in the form's settings.
The form's Origin allowlist does not include your dev origin. Add http://localhost:5173 (Vite) or http://localhost:3000 (CRA) alongside your production domain.
One HTTP call to get started