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.

Vite or Create React App
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>
  );
}

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.

Vite or Create React App
// 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.

Next.js App Router
"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.

React Hook Form
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.

TypeScript
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
File upload
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?

Why not EmailJS or your own API route?
EmailJSYour API routeFormgong
What you addAn 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.
EmailInstant 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.
TelegramNot part of EmailJS. It sends email.Only if you write that call yourself.Instant delivery on the free plan.
Keys in the browserThe 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.
SpamYou 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 quota200 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 sitsEmailJS 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

  1. Create a form in Formgong and copy the access key. It looks like fk_your_access_key.
  2. Paste the component above into the app and replace that key.
  3. Render the component, open the site and send yourself a test.
  4. In Formgong, open the form and connect Telegram if you want a phone alert.

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.

Start free