Skip to main content
ASoc
Tutorial

React Contact Form: Pick Who Sends the Mail First

React cannot send email, so the only real decision is who does. Three routes compared, plus the 40-line action this site runs — honeypot, reply_to and error hygiene.

The ASoc Team10 min read

React cannot send an email. It renders the form and holds the input; delivery needs something with a secret, which means a server, a serverless function, or a third-party form backend. So building a React contact form is really one decision — who sends the mail — and three or four lines of JSX follow from the answer. This post picks between the routes, then shows the one this storefront runs, with its code.

That framing is missing from most tutorials, which show the useState plumbing and then quietly hand your form to a hosted endpoint in the last paragraph. The plumbing is the easy half.

The short answer

Your setupWho sends the mailWhat it costs you
Vite / CRA, no backend, no plans for oneA form backend (Formspree, Web3Forms)A third party sees every submission; a free-tier cap
Vite / CRA, backend you already runYour own POST /api/contactYou write the handler and hold the API key
Next.js, Remix, SvelteKit, TanStack StartA server action or server routeNothing extra — the server is already there
Static host, one form, low volumemailto: linkNo spam handling, no delivery guarantee, opens their mail client

The rule of thumb: if your framework already has a server, use it. The form-backend services exist to solve the no-server problem, and they are a good answer to it, but you pay for them with a third party in the middle of your support inbox. If you have a server and still post to a form backend, you have taken the cost without the reason.

This storefront is on Next.js, so the rest of this post is the server-side route. If you want the deep version of that specific path — validation with no-JS fallback, a rate limit that survives serverless, the from-address deliverability trap — this repo has a dedicated write-up: a Next.js contact form with Server Actions, Zod and Resend. What follows is the shorter, decision-first version, and the parts that are the same in plain React.

The client half, which is genuinely small

Here is the actual form component this site ships, trimmed of its Tailwind classes. It is src/components/molecules/ContactForm.tsx:

"use client";
import { useActionState } from "react";
import { sendContactMessage } from "@/lib/actions/contact";
import FormStatus from "@/components/atoms/FormStatus";

export default function ContactForm() {
  const [state, formAction, pending] = useActionState(sendContactMessage, null);
  return (
    <form action={formAction}>
      <label htmlFor="contact-email">Email</label>
      <input id="contact-email" name="email" type="email" required />

      <label htmlFor="contact-message">Message</label>
      <textarea id="contact-message" name="message" rows={5} required />

      <div style={{ display: "none" }} aria-hidden="true">
        <label htmlFor="contact-company">Company</label>
        <input id="contact-company" name="company" type="text"
               tabIndex={-1} autoComplete="off" />
      </div>

      <button type="submit" disabled={pending}>
        {pending ? "Sending…" : "Send message"}
      </button>
      <FormStatus state={state} />
    </form>
  );
}

Four things in there are worth copying regardless of which route you pick:

  • No useState per field. The inputs are uncontrolled and named, and the submission reads FormData. Controlled inputs for a contact form are re-renders you are paying for and validation you are duplicating.
  • useActionState gives you pending for free. On plain React without a framework action, this is useState plus a try/finally — but keep the shape: one status object, one pending flag, rendered by one component.
  • Every input has a real <label htmlFor>. A placeholder is not a label; it disappears on focus and screen readers do not announce it as one.
  • The hidden company field is a honeypot. More on that next, because the handling is the interesting part.

The measured cost of all of this: /contact ships 12 scripts totalling 195.6 KiB gzipped, against a site-wide floor of 195.2 KiB that every static page on this storefront carries. The entire working form is 0.4 KiB gzipped over a page with no JavaScript of its own, because it is one client component and nothing else on the page hydrates. A contact form is not where your bundle goes — see React bundle size for where it actually goes.

The server half, with the three decisions in it

This is src/lib/actions/contact.ts, close to verbatim:

"use server";
import type { FormState } from "@/lib/actions/newsletter";
import { EMAIL_RE } from "@/lib/validation";

export async function sendContactMessage(
  _prev: FormState,
  formData: FormData,
): Promise<FormState> {
  // Honeypot: real users never fill this hidden field.
  if (formData.get("company")) return { ok: true, message: "Message sent." };

  const email = String(formData.get("email") ?? "").trim();
  const message = String(formData.get("message") ?? "").trim().slice(0, 5000);
  if (!email || !message) {
    return { ok: false, message: "Please fill in your email and message." };
  }
  if (!EMAIL_RE.test(email)) {
    return { ok: false, message: "Please enter a valid email address." };
  }

  const apiKey = process.env.RESEND_API_KEY;
  if (!apiKey) {
    console.error("contact: RESEND_API_KEY not configured");
    return { ok: false, message: "Something went wrong — email us at support@asoctemplates.com." };
  }

  const res = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "ASoc Contact <noreply@asoctemplates.com>",
      to: ["support@asoctemplates.com"],
      reply_to: email,
      subject: "New contact form message",
      text: `From: ${email}\n\n${message}`,
    }),
  });
  // ...error handling below
}

The honeypot returns success

Note what the honeypot branch does: it returns ok: true with "Message sent." and sends nothing. It lies to the bot.

That is deliberate and it is the opposite of what the obvious implementation does. If you return an error, the bot learns the field is a trap and the next submission leaves it empty. If you return success, the bot records a delivery that never happened and moves on. A spam filter that announces itself gets routed around.

It also costs nothing in false positives, because a real user cannot fill that field: it is display: none, aria-hidden, tabIndex={-1} and autoComplete="off" — invisible to sighted users, skipped by the tab order, not announced by screen readers, and not a target for the browser's autofill.

The from address is not the sender's address

from is your own verified domain; the visitor's address goes in reply_to. Putting the visitor's address in from is the single most common way a working contact form ends up undeliverable — you are asserting that your server is authorised to send as gmail.com, SPF and DKIM disagree, and the message is dropped or spam-foldered. With reply_to, hitting reply in your inbox still goes to the visitor, which is the behaviour you actually wanted.

Validation lives on the server, and it is small

EMAIL_RE is shared from src/lib/validation.ts so the form and the newsletter signup cannot drift:

export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

That regex is deliberately loose. It rejects the typos a human makes (no @, no dot, a space) and does not try to validate deliverability, which no regex can do. The required and type="email" attributes on the inputs are a convenience for the user; they are not validation, because the request can be replayed without a browser. The .slice(0, 5000) on the message is the other half — a length cap on the server, not a maxLength on the textarea.

The error path, and the test that holds it

The branch most tutorials omit: what the user sees when Resend returns a 500. Every failure in this action returns one fixed generic string and logs the detail server-side only:

if (!res.ok) {
  console.error("contact: resend error", res.status, await res.text());
  return { ok: false, message: "Something went wrong — email us at support@asoctemplates.com." };
}

Three failure modes — missing API key, upstream non-OK, rejected fetch — all return that same sentence. The reason is that an upstream error body is not yours and you do not know what is in it: a connection string, an internal hostname, a stack frame, a SQL error naming a table. Forwarding err.message to the UI is how that reaches a stranger's browser.

This is guarded by a shipped test rather than a convention, src/lib/__tests__/error-hygiene.test.ts. It drives both failure branches with internals deliberately injected into the upstream response and asserts the returned message is exactly the generic string:

const INTERNAL_MARKERS = [
  "PG::UniqueViolation",
  "secret_table",
  "ECONNREFUSED",
  "10.0.0.42",
  "internal-db.svc.local",
  "500",
  "at Object.<anonymous>", // stack-frame shape
];

function expectClean(message: string | undefined, generic: string) {
  expect(message).toBe(generic);
  for (const marker of INTERNAL_MARKERS) {
    expect(message).not.toContain(marker);
  }
}

Worth having because the regression is a one-word edit. Someone debugging a delivery problem changes the message to include res.status to see what is failing, it works, and it ships. A test is what makes that a red build instead of a leak.

If you have no server: what changes

On Vite or CRA, keep the component above almost as-is and swap useActionState for a submit handler. The important part is that the fetch target changes, not the form:

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  setPending(true);
  try {
    const res = await fetch("https://api.your-form-backend.com/f/<id>", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(e.currentTarget), // same FormData, no per-field state
    });
    setState(res.ok
      ? { ok: true, message: "Message sent." }
      : { ok: false, message: "Something went wrong — email us directly." });
  } catch {
    setState({ ok: false, message: "Something went wrong — email us directly." });
  } finally {
    setPending(false);
  }
}

What you give up going this route is not the UI — it is the three server-side decisions above. The honeypot runs on their rules, the generic error message is theirs, and the length cap is theirs. What you must not do is reach for the no-server route because the server route looks harder: the action above is 40 lines and holds no state.

And the thing to never do in a browser-only app: put an email provider's API key in the client, VITE_-prefixed or otherwise. Anything the browser can read, a reader can extract, and a leaked sending key becomes someone else's spam relay on your domain's reputation.

Mistakes and troubleshooting

SymptomCauseFix
Form submits, no email, no errorfrom uses the visitor's domainPut your verified domain in from, the visitor in reply_to
Mail lands in spamSending domain not verified (SPF/DKIM)Verify the domain with your provider before launch
Spam gets through the honeypotThe trap field has a guessable name like honeypotName it something a bot would plausibly fill, such as company
Honeypot catches real usersThe field is only display: none, so autofill fills itAdd aria-hidden, tabIndex={-1} and autoComplete="off"
Upstream detail shows in the UIAn error branch returns err.messageReturn a fixed string; log the detail server-side
Works locally, fails deployedAPI key not set in the host's environmentCheck for the key and fail with the generic message, as above
Submitting reloads the pageNo preventDefault on the plain-React handlerCall it first, or use a framework action

Frequently asked questions

Can React send an email without a backend? No. Sending requires a credential, and anything in a React bundle is readable by whoever loads the page. The no-backend options all work by handing the submission to someone else's server, which holds the credential for you.

Do I need a form library for a contact form? No. Two fields and a submit do not justify one — uncontrolled inputs plus FormData is less code than any library's setup. Reach for React Hook Form when you have many fields, cross-field rules, or multi-step state; see React Hook Form.

Is a honeypot enough, or do I need a CAPTCHA? A honeypot plus a server-side length cap stops the bulk of automated submissions at zero cost to the user. Add a challenge only once you observe spam getting through, since a CAPTCHA is friction for every real sender.

Should validation be on the client or the server? Both, for different jobs. Client-side attributes give fast feedback; the server check is the one that holds, because the request can be made without a browser. Share the rule from one module so they cannot disagree.

Templates in this post

ASoc Sentinel is a security-suite site covering malware, VPN and identity protection with tiered plans. ASoc Signal is an AI voice and image studio site with voice cloning and voiceover tools. ASoc Sterling is a wealth-management marketing site with advisor-backed portfolios and a balance dashboard preview.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial9 min read

React Dropdown: The Two Defects a useState Toggle Ships With

A production dropdown, defect by defect: why one `open` boolean breaks the click, and why the 8px gap has to be padding rather than margin.

Read more
Tutorial9 min read

React + EmailJS: What Ships in Your Bundle vs. a Server Action

EmailJS ships its service ID, template ID and public key in your bundle by design. This storefront's contact form ships none of that — the two files that decide it.

Read more
Tutorial10 min read

React Focus Trap: Two Dialogs Claimed aria-modal, One Meant It

An audit of four overlay surfaces: the wishlist panel that let Tab walk out of a modal dialog, the off-screen drawer pointer-events-none never hid, and the iframe case.

Read more