React contact form that sends email without a backend
Formgong team ·
Post FormData with fetch to the Formgong endpoint and add your public access key. Formgong filters spam and delivers the lead to email and Telegram. Telegram is instant. Email on the free plan is a daily digest. You do not run a server, SMTP or secret keys. Paste the component below.
Create a form, copy the access key, and replace fk_your_access_key in the component. The key is public. It belongs in this file.
import { useState } from "react";
// Paste your key: replace fk_your_access_key with the key from your Formgong form.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "en");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Could not send. Please try again.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Sent. Thank you!");
} catch {
setStatus("error");
setNotice("Connection failed. Please try again.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Name"} <input name="name" autoComplete="name" placeholder={"Your name"} required /></label>
<label>{"Email"} <input name="email" type="email" autoComplete="email" placeholder={"you@example.com"} required /></label>
<label>{"Message"} <textarea name="message" placeholder={"How can we help?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Sending…" : "Send"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// Вставте ключ: замініть fk_your_access_key на ключ вашої форми Formgong.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "uk");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Не вдалося надіслати. Спробуйте ще раз.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Надіслано. Дякуємо!");
} catch {
setStatus("error");
setNotice("Немає з’єднання. Спробуйте ще раз.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Ім’я"} <input name="name" autoComplete="name" placeholder={"Ваше ім’я"} required /></label>
<label>{"Email"} <input name="email" type="email" autoComplete="email" placeholder={"you@example.com"} required /></label>
<label>{"Повідомлення"} <textarea name="message" placeholder={"Чим ми можемо допомогти?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Надсилаємо…" : "Надіслати"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// Wklej klucz: zastąp fk_your_access_key kluczem formularza Formgong.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "pl");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Nie udało się wysłać. Spróbuj ponownie.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Wysłano. Dziękujemy!");
} catch {
setStatus("error");
setNotice("Brak połączenia. Spróbuj ponownie.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Imię"} <input name="name" autoComplete="name" placeholder={"Twoje imię"} required /></label>
<label>{"E-mail"} <input name="email" type="email" autoComplete="email" placeholder={"ty@example.com"} required /></label>
<label>{"Wiadomość"} <textarea name="message" placeholder={"W czym możemy pomóc?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Wysyłanie…" : "Wyślij"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// Anahtarınızı yapıştırın: fk_your_access_key değerini Formgong formunun anahtarıyla değiştirin.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "tr");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Gönderilemedi. Lütfen tekrar deneyin.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Gönderildi. Teşekkürler!");
} catch {
setStatus("error");
setNotice("Bağlantı kurulamadı. Lütfen tekrar deneyin.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Ad"} <input name="name" autoComplete="name" placeholder={"Adınız"} required /></label>
<label>{"E-posta"} <input name="email" type="email" autoComplete="email" placeholder={"siz@ornek.com"} required /></label>
<label>{"Mesaj"} <textarea name="message" placeholder={"Size nasıl yardımcı olabiliriz?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Gönderiliyor…" : "Gönder"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// Pega tu clave: sustituye fk_your_access_key por la clave de tu formulario de Formgong.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "es");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "No se pudo enviar. Inténtalo de nuevo.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Enviado. ¡Gracias!");
} catch {
setStatus("error");
setNotice("Error de conexión. Inténtalo de nuevo.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Nombre"} <input name="name" autoComplete="name" placeholder={"Tu nombre"} required /></label>
<label>{"Correo electrónico"} <input name="email" type="email" autoComplete="email" placeholder={"tu@ejemplo.com"} required /></label>
<label>{"Mensaje"} <textarea name="message" placeholder={"¿En qué podemos ayudarte?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Enviando…" : "Enviar"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// Collez votre clé : remplacez fk_your_access_key par la clé de votre formulaire Formgong.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "fr");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Échec de l’envoi. Veuillez réessayer.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Envoyé. Merci !");
} catch {
setStatus("error");
setNotice("Connexion impossible. Veuillez réessayer.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Nom"} <input name="name" autoComplete="name" placeholder={"Votre nom"} required /></label>
<label>{"E-mail"} <input name="email" type="email" autoComplete="email" placeholder={"vous@exemple.com"} required /></label>
<label>{"Message"} <textarea name="message" placeholder={"Comment pouvons-nous vous aider ?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Envoi…" : "Envoyer"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// Cole a sua chave: substitua fk_your_access_key pela chave do formulário Formgong.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "pt");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Não foi possível enviar. Tente novamente.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Enviado. Obrigado!");
} catch {
setStatus("error");
setNotice("Falha na ligação. Tente novamente.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Nome"} <input name="name" autoComplete="name" placeholder={"O seu nome"} required /></label>
<label>{"E-mail"} <input name="email" type="email" autoComplete="email" placeholder={"voce@exemplo.com"} required /></label>
<label>{"Mensagem"} <textarea name="message" placeholder={"Como podemos ajudar?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "A enviar…" : "Enviar"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// الصق مفتاحك: استبدل fk_your_access_key بمفتاح نموذج Formgong.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "ar");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "تعذّر الإرسال. يرجى المحاولة مرة أخرى.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "تم الإرسال. شكرًا لك!");
} catch {
setStatus("error");
setNotice("فشل الاتصال. يرجى المحاولة مرة أخرى.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"الاسم"} <input name="name" autoComplete="name" placeholder={"اسمك"} required /></label>
<label>{"البريد الإلكتروني"} <input name="email" type="email" autoComplete="email" placeholder={"you@example.com"} required /></label>
<label>{"الرسالة"} <textarea name="message" placeholder={"كيف يمكننا مساعدتك؟"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "جارٍ الإرسال…" : "إرسال"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// הדביקו את המפתח: החליפו את fk_your_access_key במפתח של טופס Formgong.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "he");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "השליחה נכשלה. נא לנסות שוב.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "נשלח. תודה!");
} catch {
setStatus("error");
setNotice("אין חיבור. נא לנסות שוב.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"שם"} <input name="name" autoComplete="name" placeholder={"השם שלך"} required /></label>
<label>{"אימייל"} <input name="email" type="email" autoComplete="email" placeholder={"you@example.com"} required /></label>
<label>{"הודעה"} <textarea name="message" placeholder={"איך נוכל לעזור?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "שולחים…" : "שליחה"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// अपनी कुंजी चिपकाएँ: fk_your_access_key को अपने Formgong फ़ॉर्म की कुंजी से बदलें।
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "hi");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "भेजा नहीं जा सका। कृपया फिर से कोशिश करें।");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "भेज दिया गया। धन्यवाद!");
} catch {
setStatus("error");
setNotice("कनेक्शन विफल। कृपया फिर से कोशिश करें।");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"नाम"} <input name="name" autoComplete="name" placeholder={"आपका नाम"} required /></label>
<label>{"ईमेल"} <input name="email" type="email" autoComplete="email" placeholder={"you@example.com"} required /></label>
<label>{"संदेश"} <textarea name="message" placeholder={"हम आपकी कैसे मदद कर सकते हैं?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "भेजा जा रहा है…" : "भेजें"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// キーを貼る: fk_your_access_key を Formgong のフォームのキーに置き換えてください。
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "ja");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "送信できませんでした。もう一度お試しください。");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "送信しました。ありがとうございます。");
} catch {
setStatus("error");
setNotice("接続できませんでした。もう一度お試しください。");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"お名前"} <input name="name" autoComplete="name" placeholder={"山田 太郎"} required /></label>
<label>{"メールアドレス"} <input name="email" type="email" autoComplete="email" placeholder={"you@example.com"} required /></label>
<label>{"メッセージ"} <textarea name="message" placeholder={"ご用件をご記入ください"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "送信中…" : "送信"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}import { useState } from "react";
// Schlüssel einfügen: ersetzen Sie fk_your_access_key durch den Schlüssel Ihres Formgong-Formulars.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "de");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Das Senden ist fehlgeschlagen. Bitte versuchen Sie es erneut.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Gesendet. Vielen Dank!");
} catch {
setStatus("error");
setNotice("Die Verbindung ist fehlgeschlagen. Bitte versuchen Sie es erneut.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Name"} <input name="name" autoComplete="name" placeholder={"Ihr Name"} required /></label>
<label>{"E-Mail"} <input name="email" type="email" autoComplete="email" placeholder={"name@beispiel.de"} required /></label>
<label>{"Nachricht"} <textarea name="message" placeholder={"Wie können wir Ihnen helfen?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Senden…" : "Senden"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}Other React setups
Vite or Create React App
The component above is this setup. Save it as src/ContactForm.jsx and render it from App. No extra package.
// src/App.jsx — Vite or Create React App
import ContactForm from "./ContactForm";
export default function App() {
return <ContactForm />;
}Next.js App Router
Add the client directive at the top of the file. The page that renders the form can stay a Server Component.
"use client";
import { useState } from "react";
// Paste your key: replace fk_your_access_key with the key from your Formgong form.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "en");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Could not send. Please try again.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Sent. Thank you!");
} catch {
setStatus("error");
setNotice("Connection failed. Please try again.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Name"} <input name="name" autoComplete="name" placeholder={"Your name"} required /></label>
<label>{"Email"} <input name="email" type="email" autoComplete="email" placeholder={"you@example.com"} required /></label>
<label>{"Message"} <textarea name="message" placeholder={"How can we help?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Sending…" : "Send"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}
// app/contact/page.jsx — this file can stay a Server Component.
import ContactForm from "./ContactForm";
export default function Page() {
return <ContactForm />;
}React Hook Form
Install react-hook-form. Keep its validation. The submit handler still posts FormData to Formgong.
import { useState } from "react";
import { useForm } from "react-hook-form";
// Paste your key: replace fk_your_access_key with the key from your Formgong form.
const ACCESS_KEY = "fk_your_access_key";
export default function ContactForm() {
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm();
const [notice, setNotice] = useState("");
const [failed, setFailed] = useState(false);
async function onSubmit(values) {
const body = new FormData();
body.set("access_key", ACCESS_KEY);
body.set("_lang", "en");
body.set("name", values.name);
body.set("email", values.email);
body.set("message", values.message);
body.set("botcheck", values.botcheck || "");
setFailed(false);
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setFailed(true);
setNotice(result.message || "Could not send. Please try again.");
return;
}
reset();
setNotice(result.message || "Sent. Thank you!");
} catch {
setFailed(true);
setNotice("Connection failed. Please try again.");
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>{"Name" <input {...register("name", { required: "Required" })} aria-invalid={errors.name ? true : undefined} autoComplete="name" /></label>
{errors.name && <span>{errors.name.message}</span>}
<label>{"Email" <input type="email" {...register("email", { required: "Required" })} aria-invalid={errors.email ? true : undefined} autoComplete="email" /></label>
{errors.email && <span>{errors.email.message}</span>}
<label>{"Message" <textarea {...register("message", { required: "Required" })} aria-invalid={errors.message ? true : undefined} /></label>
{errors.message && <span>{errors.message.message}</span>}
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input {...register("botcheck")} tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={isSubmitting}>{isSubmitting ? "Sending…" : "Send"}</button>
{failed ? <p role="alert">{notice}</p> : <p role="status">{notice}</p>}
</form>
);
}TypeScript
The same component, with a type for the status and for the JSON result. Save it as ContactForm.tsx.
import { useState, type FormEvent } from "react";
// Paste your key: replace fk_your_access_key with the key from your Formgong form.
const ACCESS_KEY = "fk_your_access_key";
type Status = "idle" | "sending" | "success" | "error";
type SubmitResult = { success?: boolean; message?: string };
export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
if (!form.reportValidity()) return;
const body = new FormData(form);
body.set("access_key", ACCESS_KEY);
body.set("_lang", "en");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = (await response.json()) as SubmitResult;
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Could not send. Please try again.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Sent. Thank you!");
} catch {
setStatus("error");
setNotice("Connection failed. Please try again.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>{"Name"} <input name="name" autoComplete="name" placeholder={"Your name"} required /></label>
<label>{"Email"} <input name="email" type="email" autoComplete="email" placeholder={"you@example.com"} required /></label>
<label>{"Message"} <textarea name="message" placeholder={"How can we help?"} required /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Sending…" : "Send"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}Common problems
On the free plan, email is a daily digest to one address. It goes out the next morning at 08:00 in your time zone and covers the previous day.
Telegram is instant on every plan, including Free. Pro and Business email each lead.
CORS
The submit route sends Access-Control-Allow-Origin: *. A page on your domain, including localhost, may call it.
On Pro you can limit the form to your domains. If that list is on and the page host is missing, the lead is refused.
Double submit
Each successful POST is a new lead. The sample disables the button while status is sending, so a second click does not send twice.
React Strict Mode runs some setup twice in development. It does not submit the form by itself. A second click does.
Controlled inputs reset
The sample uses uncontrolled inputs. form.reset() clears them after a success.
If you store the field in useState and set value to that state, reset clears the DOM and React writes the old state back.
Clear that state in the success branch, or leave the inputs uncontrolled.
CSP connect-src
If the page sets a Content-Security-Policy, fetch is blocked unless connect-src includes https://formgong.com.
A plain form POST is a navigation. If you also set form-action, allow the same host there.
Testing on localhost
Open the page on localhost and send a test. The browser still calls formgong.com, and the lead shows in the inbox.
Free accepts that origin. On Pro, add localhost to the allow list if you turned the list on.
Next.js use client
The form uses useState, so the file needs the client directive at the top. Without it, Next.js treats the file as a Server Component and the build fails.
The page that renders the form can stay a Server Component. Import the client file from that page.
File upload
Add a file input named attachment and post FormData. Do not set Content-Type yourself. The browser must set the multipart boundary.
Turn on attachments in the form settings. Verify the account email, and add your Turnstile site key under Protection. Uploads stay off until those are in place.
The server accepts PDF, JPEG and PNG only. The cap is 3 files, 5 MiB each and 10 MiB combined. The sample checks the same numbers before it sends.
Free stores 100.0 MiB of files. Pro stores 1.0 GiB. Business stores 5.0 GiB. Files follow the form's retention period and are deleted with the submission.
Only the form owner can download a file, and only after signing in. The Telegram alert does not attach the file.
On the free plan the email is still the daily digest. The file is in the inbox either way. Telegram is instant.
The HTML version, with the error codes and what each channel receives, is the contact form with file upload guide.
File upload component
import { useState } from "react";
// Paste your key: replace fk_your_access_key with the key from your Formgong form.
// Turn on attachments in the form settings, verify the account email, and add Turnstile.
// This sample checks size in the browser. The server enforces the same limits.
const ACCESS_KEY = "fk_your_access_key";
const MAX_FILES = 3;
const MAX_EACH = 5242880;
const MAX_TOTAL = 10485760;
export default function ContactFormWithFile() {
const [status, setStatus] = useState("idle");
const [notice, setNotice] = useState("");
async function onSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
if (!(form instanceof HTMLFormElement) || !form.reportValidity()) return;
const body = new FormData(form);
const picked = [...body.getAll("attachment")].filter((item) => item instanceof File && item.size > 0);
const bytes = picked.reduce((sum, file) => sum + file.size, 0);
if (picked.length > MAX_FILES || picked.some((file) => file.size > MAX_EACH) || bytes > MAX_TOTAL) {
setStatus("error");
setNotice("Up to 3 PDF, JPEG or PNG files, 5 MiB each and 10 MiB combined.");
return;
}
body.set("access_key", ACCESS_KEY);
body.set("_lang", "en");
setStatus("sending");
setNotice("");
try {
const response = await fetch("https://formgong.com/submit", {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const result = await response.json();
if (!response.ok || !result.success) {
setStatus("error");
setNotice(result.message || "Could not send. Please try again.");
return;
}
form.reset();
setStatus("success");
setNotice(result.message || "Sent. Thank you.");
} catch {
setStatus("error");
setNotice("You are offline. Check the connection and try again.");
}
}
const sending = status === "sending";
return (
<form onSubmit={onSubmit}>
<label>Name <input name="name" autoComplete="name" required /></label>
<label>Email <input name="email" type="email" autoComplete="email" required /></label>
<label>Message <textarea name="message" required /></label>
<label>File <input name="attachment" type="file" accept=".pdf,.jpg,.jpeg,.png" multiple /></label>
<div aria-hidden="true" style={{ position: "absolute", insetInlineStart: 0, top: 0, width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
<input name="botcheck" tabIndex={-1} autoComplete="off" />
</div>
<button type="submit" disabled={sending}>{sending ? "Sending…" : "Send"}</button>
<p role={status === "error" ? "alert" : "status"}>{notice}</p>
</form>
);
}Ready React component on GitHub: github.com/formgong/react-contact-form. MIT license: click “Use this template” or copy the files.
React package: npm install @formgong/react (npm: @formgong/react)
Add to an existing project: npx formgong init (npm: formgong)
How forms work in React
React draws the form. It does not send mail. Vite and Create React App ship static files, so the browser needs a URL that accepts the POST.
That URL is Formgong. The component posts FormData with fetch to /submit. It sends your public access_key, _lang and an empty botcheck field. Formgong filters spam and delivers the lead.
What is different here
- Telegram arrives at once on the free plan. Email is included too, as a daily digest for one recipient.
- Submissions are stored in the EU, on Cloudflare D1 under EU jurisdiction.
- Spam filtering does not need a CAPTCHA or cookies. The honeypot is always on. Turnstile is optional.
- The access key in the component is public. You do not ship an SMTP password or a private API key.
- Error text and the thank-you page can follow the visitor in 12 languages.
Why not EmailJS or your own API route?
| EmailJS | Your API route | Formgong | |
|---|---|---|---|
| What you add | An npm package, a service id, a template id and a public key. | A serverless function and an SMTP client such as Nodemailer. | One public access key in the component on this page. |
| Instant mail from your own template. The free plan is 200 requests a month and 2 templates. | Instant mail when your provider accepts the message. | A daily digest on the free plan. Each lead is emailed on Pro and Business. | |
| Telegram | Not part of EmailJS. It sends email. | Only if you write that call yourself. | Instant delivery on the free plan. |
| Keys in the browser | The public key is meant to be public. Keep the private key on a server. | The SMTP password stays in the host environment. That is the strong point. | The access key is public. There is no SMTP secret. |
| Spam | You set the limits in the EmailJS dashboard. The 2024 Webtips tutorial adds none. | You build the filter. | A honeypot is always on. No cookies. A CAPTCHA is not required. |
| Free quota | 200 requests a month, then extra requests are dropped. | Your host's free tier, plus your mail provider's cap. | 300 submissions a month. |
| Where data sits | EmailJS stores history on its servers. Free keeps 7 days. The pricing page does not say EU-only. | Wherever your host and your mail provider store it. | In the EU, on Cloudflare D1. |
Choose EmailJS when the mail must leave from your own inbox, with your template, the moment someone submits.
Choose a Vercel or Netlify function when the submit must update your database or check that the visitor is logged in.
Choose Formgong for a contact form. Telegram is instant on the free plan. Email on that plan is a daily digest. You store no SMTP secret.
EmailJS figures come from its pricing page on 6 October 2026. The API-route column is a Vercel or Netlify function plus SMTP, not one vendor's price list.
Set it up in React
- Create a form in Formgong and copy the access key. It looks like
fk_your_access_key. - Paste the component above into the app and replace that key.
- Render the component, open the site and send yourself a test.
- In Formgong, open the form and connect Telegram if you want a phone alert.
Starters and the docs
Questions and answers
Do I need a backend for a React contact form?
No. Post FormData with fetch to Formgong. You do not add an API route, Nodemailer or SMTP.
How do I send an email from React without Nodemailer or SMTP?
Use the component on this page. Formgong accepts the POST and filters spam. Telegram gets the lead at once. On the free plan, email is a daily digest. Pro and Business email you for each submission.
Is it safe to put the access key in client code?
Yes. The access_key is public. It can only submit this form. It cannot read the inbox. On Pro you can limit the allowed domains. Never put an SMTP password in the bundle.
How do I stop spam on a React contact form?
Leave botcheck empty. Formgong scores spam with no cookies and no required CAPTCHA. Turnstile is optional if you later want a puzzle.
Does this work with Next.js?
Yes. In the App Router, add "use client" at the top of the file. The component stays the same. The Next.js variant on this page shows the file.
Articles on this topic
Related pages
Guides
- Formgong for agencies
- Static website contact form
- Netlify Forms alternative
- GDPR-compliant contact form
- Formspree alternatives
- Web3Forms alternatives
- EmailJS alternatives
- Formspree vs Web3Forms
- Formspree vs EmailJS
- Best free form backends
- Free website templates
- Testimonial and rating widget
- Craftline: plumber website template
- Orrery: consulting website template
- Lattice: portfolio website template
- Lattice Estate: home builder website template
- Shoal: real estate agent website template
- Lattice Atelier: windows and doors website template
- Soglia: designer windows website template
- Halftone: architecture studio website template
- Ocra: shop and maker website template
- Arcwell: interior design studio website template
- AI builder prompts
- Form checker
- Agent rules files
- Tools