# 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.

Published 2026-10-26.

**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.

```jsx title="ContactForm.jsx"
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](https://nisuform.com) 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:

```js title="app/api/contact/route.js"
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](https://nisuform.com), but the React code is the same for any of them. If you are choosing, see how [Nisuform compares to Formspree](/vs/formspree) and [to Web3Forms](/vs/web3forms).



## Build the React contact form step by step

### Create a form and copy its endpoint

Sign in at [app.nisuform.com](https://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`. `FormData` only 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.

```jsx
<label>
  Email
  <input name="email" type="email" autoComplete="email" required />
</label>
```

`type="email"` and `required` make the browser check the answer before anything is sent. `autoComplete` lets visitors fill in their name and email with one tap.

### Send it with a form action

`useActionState` takes an async function and an initial state, and returns three things: the latest state, an action to pass to `<form action>`, and `isPending`, which is `true` while the request is in flight.

```jsx
const [state, formAction, isPending] = useActionState(send, { status: 'idle', message: '' })
```

When the form is submitted, React calls `send(previousState, formData)` with a ready-made `FormData`. You don't call `event.preventDefault()` or read the inputs yourself. Whatever `send` returns becomes the new `state`. Early React 19 canaries called this hook `useFormState` and exported it from `react-dom`. In stable React 19, import `useActionState` from `react`.

Inside `send`, the `Accept: application/json` header tells Nisuform to answer with JSON instead of a thank-you page. Don't set `Content-Type` when the body is `FormData`. The browser sets it, with the boundary it needs.

### Show the result

The response has `"ok": true` for a stored submission, or `{ "ok": false, "error": "rate_limited", "message": "Too many requests. Try again shortly" }` when something is wrong. `message` is written for people, so the component shows it as is. If you want different wording, branch on `error`, which never changes. Every code is listed in [Errors](/docs/reference/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:

```jsx
return { status: 'error', message: result.message, values }
```

and passing them to each field as its default:

```jsx
<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](#react-18-contact-form-with-usestate-and-onsubmit) 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`:

```jsx title="SubmitButton.jsx"
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`:

```jsx title="ContactForm.jsx"
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:

```tsx title="ContactForm.tsx"
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](/docs/connect/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:

* `required` on every field you need.
* `type="email"` for email addresses, `type="tel"` for phone numbers and `type="url"` for websites.
* `minLength` and `maxLength` on the message, like `minLength={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](/docs/forms/fields), 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:

```jsx
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 `_gotcha` input. People never see it, so they leave it empty. Bots that fill in every field get dropped. Hide it with `display: none` as shown, not `type="hidden"`, because bots skip hidden inputs.
* **Fill time.** `_elapsed` is 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](/docs/protection/access-rules). If a form still gets hit, add a [Cloudflare Turnstile](/docs/protection/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](/blog/html-forms-without-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](/docs/integrations), or to your own code with a [webhook](/docs/integrations/webhooks), and send the visitor an [auto-reply](/docs/submissions/auto-replies). None of it touches your React code.

## Checklist before you ship

1. `YOUR_FORM_KEY` is replaced with your form's key.
2. Every field has a `name` and sits inside a `<label>`.
3. The request sends `Accept: application/json` and no `Content-Type` header with `FormData`.
4. The honeypot is hidden with CSS, not `type="hidden"`.
5. Errors keep what the visitor typed. Test it by turning off your network and pressing send.
6. The status message is in an element with `role="status"`.
7. 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](/docs/frameworks/nextjs), [Vue](/docs/frameworks/vue), [Svelte](/docs/frameworks/svelte) and [Astro](/docs/frameworks/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.
