React Contact Form: Send Email Without a Backend
Build a React 19 contact form with useActionState and TypeScript that emails you every submission and filters spam, without a backend of your own.
Nisuform team
13 min read

Short answer: React runs in the browser, and a browser can't send email on its own. Post the form to a form backend endpoint with fetch, and the service emails you every submission. In React 19, pass a useActionState action to the form's action prop and you get the loading state, the result and a form reset without writing any extra state.
import { useActionState, useEffect, useRef } from 'react'
const endpoint = 'https://api.nisuform.com/s/YOUR_FORM_KEY'
export function ContactForm() {
const shownAt = useRef(0)
useEffect(() => {
shownAt.current = Date.now()
}, [])
async function send(previousState, formData) {
formData.set('_elapsed', String(Date.now() - shownAt.current))
const values = Object.fromEntries(formData)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
})
const result = await response.json()
if (result.ok) {
return { status: 'sent', message: 'Thanks! We got your message.' }
}
return { status: 'error', message: result.message, values }
} catch {
return { status: 'error', message: 'Something went wrong. Please try again.', values }
}
}
const [state, formAction, isPending] = useActionState(send, { status: 'idle', message: '' })
return (
<form action={formAction}>
<label>
Name
<input name="name" type="text" autoComplete="name" required defaultValue={state.values?.name} />
</label>
<label>
Email
<input name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
</label>
<label>
Message
<textarea name="message" rows={5} required defaultValue={state.values?.message} />
</label>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: 'none' }} />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send message'}
</button>
<p role="status">{state.message}</p>
</form>
)
}Replace YOUR_FORM_KEY with the key from a free Nisuform form, render <ContactForm /> on any page, and every message arrives in your inbox and your email.
Why React can't send email on its own
React code runs in your visitor's browser. To send an email, something has to talk to a mail server over SMTP or call an email API like Resend, Postmark or SendGrid, and both need a password or API key. Everything in a React bundle is public. Anyone can open DevTools, copy the key and send email from your account.
So a React contact form has to post its fields to a server that holds the secret and sends the email.
Four ways to send a React contact form to email
| Approach | Where the email is sent from | Spam protection | Submissions saved | Upkeep |
|---|---|---|---|---|
mailto: link | The visitor's email app | None | No | None, but many messages are never sent |
| EmailJS | Your own mail account, through a template | Optional CAPTCHA | Only as emails in your mailbox | Templates and a connected mail account |
| Your own API route | Your server, with Resend or Nodemailer | Whatever you build | Only if you add a database | Code, DNS records, spam rules and hosting |
| Form backend | The form backend | Built in | Yes, in an inbox | None |
1. A mailto: link
A mailto: link or form action opens the visitor's email app with the message filled in. Anyone who only uses webmail, or is on a shared computer, gets an error or nothing at all, and you never hear about it. It also puts your address in the page for scrapers.
2. EmailJS
EmailJS is the library most React contact form tutorials reach for. You connect your Gmail or Outlook account, write an email template with placeholders like {{name}}, then call emailjs.sendForm() from the browser with a service ID, a template ID and a public key.
It is quick to set up, but each submission is only an email sent from your account, so a message that lands in spam or bounces is easy to lose, and the IDs and key ship in your bundle for anyone to reuse. Putting them in VITE_ or REACT_APP_ environment variables doesn't hide them, because both are compiled into the JavaScript the browser downloads.
3. Your own API route with Resend or Nodemailer
If your React app already runs on a server, like Next.js on Vercel, you can write the endpoint yourself. A route handler that emails you through Resend looks like this:
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function POST(request) {
const data = await request.formData()
await resend.emails.send({
from: 'Contact form <form@yourdomain.com>',
to: 'you@yourdomain.com',
replyTo: data.get('email'),
subject: `New message from ${data.get('name')}`,
text: data.get('message'),
})
return Response.json({ ok: true })
}To ship it, you also need a verified sending domain with SPF and DKIM records, validation, a honeypot, rate limits, and somewhere to keep the message when the email fails. It also only works where you can run server code, so not on GitHub Pages or a plain Vite build. It is worth it when forms are core to your product.
4. A form backend
A form backend gives you one URL that does all of the above. Your React component posts to it, and the service validates, filters spam, stores the submission and emails you. You keep your own JSX and CSS.
Nisuform, Formspree and Web3Forms all work this way. The examples here use Nisuform, but the React code is the same for any of them. If you are choosing, see how Nisuform compares to Formspree and to Web3Forms.
Build the React contact form step by step
Create a form and copy its endpoint
Sign in at app.nisuform.com, choose Create a form, then Endpoint only. The endpoint is on the form's Setup tab and looks like
https://api.nisuform.com/s/YOUR_FORM_KEY. The same tab, under Code, has this React component with your endpoint already filled in.Write the fields
Give every input a
name.FormDataonly collects fields that have one, and the name is the label you see in your inbox. Wrap each input in a<label>so screen readers announce it and tapping the text focuses the field.<label> Email <input name="email" type="email" autoComplete="email" required /> </label>type="email"andrequiredmake the browser check the answer before anything is sent.autoCompletelets visitors fill in their name and email with one tap.Send it with a form action
useActionStatetakes an async function and an initial state, and returns three things: the latest state, an action to pass to<form action>, andisPending, which istruewhile the request is in flight.const [state, formAction, isPending] = useActionState(send, { status: 'idle', message: '' })When the form is submitted, React calls
send(previousState, formData)with a ready-madeFormData. You don't callevent.preventDefault()or read the inputs yourself. Whateversendreturns becomes the newstate. Early React 19 canaries called this hookuseFormStateand exported it fromreact-dom. In stable React 19, importuseActionStatefromreact.Inside
send, theAccept: application/jsonheader tells Nisuform to answer with JSON instead of a thank-you page. Don't setContent-Typewhen the body isFormData. The browser sets it, with the boundary it needs.Show the result
The response has
"ok": truefor a stored submission, or{ "ok": false, "error": "rate_limited", "message": "Too many requests. Try again shortly" }when something is wrong.messageis written for people, so the component shows it as is. If you want different wording, branch onerror, which never changes. Every code is listed in Errors.The
<p role="status">at the bottom is a live region. Screen readers announce its text when it changes, so visitors who can't see the form still hear "Thanks! We got your message."Send a real test
Deploy, fill in the form and press Send message. The message shows up in your Nisuform inbox, and a notification email arrives within seconds. Wait a couple of seconds before you submit, because forms sent faster than a person could type are dropped as bots.
Why does my React form clear after submit?
When a form's action is a function, React resets every uncontrolled field once the action finishes. On success that is what you want. But the action also "finishes" when it returns an error, so a visitor who hits a rate limit or a dropped connection loses the message they just typed.
The component above fixes this by sending the typed values back with the error:
return { status: 'error', message: result.message, values }and passing them to each field as its default:
<textarea name="message" rows={5} required defaultValue={state.values?.message} />React commits the new state before it resets the form, so the reset fills each field with what the visitor typed. After a successful send, values is gone, and the fields clear as normal.
If you would rather control the reset yourself, use onSubmit with event.preventDefault(), as in the React 18 version below. React never resets a form that you submit that way.
Show a loading state with useFormStatus
isPending from useActionState is enough when the button lives in the same component. If your submit button is a shared component from your design system, it can read the form's state itself with useFormStatus:
import { useFormStatus } from 'react-dom'
export function SubmitButton({ children }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : children}
</button>
)
}Render it inside the form as <SubmitButton>Send message</SubmitButton>. useFormStatus comes from react-dom, not react, and only works in a component rendered inside a <form>. Disabling the button while the request is pending also stops double submissions from repeated clicks.
React 18 contact form with useState and onSubmit
useActionState and form actions need React 19. On React 18, or if you prefer to handle the submit event yourself, use onSubmit:
import { useEffect, useRef, useState } from 'react'
const endpoint = 'https://api.nisuform.com/s/YOUR_FORM_KEY'
export function ContactForm() {
const shownAt = useRef(0)
const [sending, setSending] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
shownAt.current = Date.now()
}, [])
async function handleSubmit(event) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
formData.set('_elapsed', String(Date.now() - shownAt.current))
setSending(true)
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
})
const result = await response.json()
if (result.ok) {
form.reset()
setMessage('Thanks! We got your message.')
} else {
setMessage(result.message)
}
} catch {
setMessage('Something went wrong. Please try again.')
} finally {
setSending(false)
}
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input name="name" type="text" autoComplete="name" required />
</label>
<label>
Email
<input name="email" type="email" autoComplete="email" required />
</label>
<label>
Message
<textarea name="message" rows={5} required />
</label>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: 'none' }} />
<button type="submit" disabled={sending}>
{sending ? 'Sending...' : 'Send message'}
</button>
<p role="status">{message}</p>
</form>
)
}event.currentTarget is only set while the event is being handled and is null after the first await, which is why the form is stored in form first. This version works in every React version with hooks, back to 16.8.
React contact form in TypeScript
Only the state type and the action's signature change. The JSX is the same as the JavaScript version:
import { useActionState, useEffect, useRef } from 'react'
type FormState = {
status: 'idle' | 'sent' | 'error'
message: string
values?: Record<string, string>
}
const endpoint = 'https://api.nisuform.com/s/YOUR_FORM_KEY'
const initialState: FormState = { status: 'idle', message: '' }
export function ContactForm() {
const shownAt = useRef(0)
useEffect(() => {
shownAt.current = Date.now()
}, [])
async function send(previousState: FormState, formData: FormData): Promise<FormState> {
formData.set('_elapsed', String(Date.now() - shownAt.current))
const values = Object.fromEntries(formData) as Record<string, string>
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
})
const result = await response.json()
if (result.ok) {
return { status: 'sent', message: 'Thanks! We got your message.' }
}
return { status: 'error', message: result.message, values }
} catch {
return { status: 'error', message: 'Something went wrong. Please try again.', values }
}
}
const [state, formAction, isPending] = useActionState(send, initialState)
return (
<form action={formAction}>
<label>
Name
<input name="name" type="text" autoComplete="name" required defaultValue={state.values?.name} />
</label>
<label>
Email
<input name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
</label>
<label>
Message
<textarea name="message" rows={5} required defaultValue={state.values?.message} />
</label>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: 'none' }} />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send message'}
</button>
<p role="status">{state.message}</p>
</form>
)
}The cast on Object.fromEntries is safe here because the form has no file inputs, so every value is a string. If you add file uploads, drop file fields from values before passing them to defaultValue.
Validate the form before it sends
Start with the browser. These attributes block the submit and show a message in the visitor's language, with no JavaScript:
requiredon every field you need.type="email"for email addresses,type="tel"for phone numbers andtype="url"for websites.minLengthandmaxLengthon the message, likeminLength={10}to stop one-word spam.
Form actions respect all of them. React only calls your action once the browser's own checks pass. To style a field the visitor got wrong, use the CSS :user-invalid selector, which waits until they have interacted with the field instead of flagging empty fields on page load.
Bots can skip browser validation, so Nisuform checks again. Add your fields in the form builder, and submissions with a bad email or a missing answer are still stored but flagged in your inbox.
If your app already uses React Hook Form, keep it, and send the same request from its submit handler:
const { register, handleSubmit, reset } = useForm()
async function onSubmit(values) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(values),
})
const result = await response.json()
if (result.ok) reset()
}Pass it to the form as <form onSubmit={handleSubmit(onSubmit)}>, and spread register('email') onto each input.
Stop spam without a CAPTCHA
The component above already sends two invisible checks:
- Honeypot. The hidden
_gotchainput. People never see it, so they leave it empty. Bots that fill in every field get dropped. Hide it withdisplay: noneas shown, nottype="hidden", because bots skip hidden inputs. - Fill time.
_elapsedis the number of milliseconds between the form appearing and the submit. A person needs a few seconds. Anything under 2 seconds is dropped.
Bots caught by either check still get { "ok": true }, so they can't tell what gave them away. On top of that, Nisuform rate limits each visitor, sends submissions stuffed with links to a spam folder, and can reject posts from any site that isn't on your form's allowed websites. If a form still gets hit, add a Cloudflare Turnstile widget. Its hidden token is part of FormData, so the React code doesn't change.
How to add a contact form to a static website without a backend goes deeper on every check and when each one is worth it.
Where to put the component: Vite, Next.js, React Router and Astro
The component posts from the browser, so it works anywhere React renders. Only the file location changes:
| Setup | Where it goes | Note |
|---|---|---|
| Vite | src/ContactForm.jsx | Import it into any page or route. |
| Next.js App Router | app/contact/ContactForm.jsx | Add 'use client' as the first line, because the component uses hooks. Works with output: 'export'. |
| Next.js Pages Router | components/ContactForm.jsx | Works as is. |
| React Router or Remix | app/components/ContactForm.jsx | No route action or clientAction is needed. |
| Gatsby | src/components/ContactForm.jsx | Works as is, with no Gatsby Functions. |
| Astro | src/components/ContactForm.jsx | Render it as <ContactForm client:load /> with the React integration. |
Create React App is deprecated, so start new projects with Vite or a framework. The component itself still works in an existing CRA app.
The form key is safe to publish, so you don't need an environment variable. If you want a separate test form in development, you can still read the endpoint from one, like import.meta.env.VITE_FORM_ENDPOINT in Vite.
Send submissions to Slack, Discord or Google Sheets
Email is only the default. On Pro, the same React form can post each submission to Slack, Discord, Telegram or Google Sheets, or to your own code with a webhook, and send the visitor an auto-reply. None of it touches your React code.
Checklist before you ship
YOUR_FORM_KEYis replaced with your form's key.- Every field has a
nameand sits inside a<label>. - The request sends
Accept: application/jsonand noContent-Typeheader withFormData. - The honeypot is hidden with CSS, not
type="hidden". - Errors keep what the visitor typed. Test it by turning off your network and pressing send.
- The status message is in an element with
role="status". - You sent a test from the live site. If you set allowed websites, your dev URL, like
http://localhost:5173, is on the list too.
For other frameworks, see the Next.js, Vue, Svelte and Astro guides.
Frequently asked questions
Can a React contact form send email without a backend?
Not by itself. React runs in the visitor's browser, and sending email needs a mail server or an email API with a secret key that must never be in browser code. Point the form at a form backend instead. It receives each submission, filters spam, stores it and emails you, so you don't write or host any server code. Nisuform's Free plan covers 100 submissions a month and needs no credit card.
Is it safe to put the form endpoint in my React code?
Yes. A Nisuform endpoint only accepts new submissions. Nobody can read your submissions or change your form with it, so it is fine in a public bundle. That is different from an SMTP password or an email API key, which lets anyone who copies it send email as you. To stop other sites from posting to your endpoint, add your domains to the form's allowed websites.
Should I use EmailJS for a React contact form?
EmailJS works for a quick prototype, but every message is only an email sent through your own mail account, spam protection is up to you, and the IDs and public key it needs sit in your bundle. A form backend also emails you, and it stores every submission, filters spam and can forward messages to Slack, Discord or Google Sheets.
Why does my React form clear after I submit it?
In React 19, when a form's action prop is a function, React resets every uncontrolled field once the action finishes, even if your action returned an error. Return the submitted values with the error and pass them back to each field as defaultValue, and the reset fills the fields with what the visitor typed. With onSubmit and event.preventDefault, React doesn't reset anything for you.
Does this React contact form work with Next.js, Vite and React Router?
Yes. The form posts from the browser straight to the form backend, so it works in Vite, Next.js, React Router, Remix, Gatsby and Astro islands, and on static hosts like GitHub Pages, Netlify, Vercel and Cloudflare Pages. In the Next.js App Router, add 'use client' at the top of the file, because the component uses hooks.
Do I need React Hook Form or Formik for a contact form?
No. A contact form with three fields only needs a form action, the browser's built-in validation and a status message. React Hook Form and Formik help with large forms, multi-step flows and complex validation. If you already use one, keep it and send the same fetch request from its submit handler.
How do I test a React contact form on localhost?
Run your dev server and submit the form. It works from localhost unless you set allowed websites, in which case add your dev URL, like http://localhost:5173 for Vite. Wait at least 2 seconds before you submit, because faster submissions are treated as bots, and keep to 5 test submissions a minute per form, the rate limit for one IP address.

