# Send email from a React form without a backend

React can't send email, so your form needs somewhere to post. Point it at a Nisuform endpoint and each submission is checked for spam, saved and emailed to you. Free for 100 submissions a month.

Updated Oct 29, 2026.

React runs in the browser, and a browser can't send email. Most tutorials fix that with an API route that calls Resend or SendGrid. That means hosting a server, verifying a sending domain, keeping an API key secret and writing your own spam checks.

A form backend takes the place of that route. The form posts to a Nisuform endpoint, which checks it for spam, saves it to your inbox and emails it to you. Your app stays static and can live on any host.

## A hook for any form

Copy this hook into your project. It reads the fields with `FormData`, adds the fill time the spam check uses, and turns the JSON response into a status.

```tsx title="useFormBackend.ts"
import { useRef, useState, type FormEvent } from 'react'

type Status = 'idle' | 'sending' | 'sent' | 'error'

export function useFormBackend(endpoint: string) {
  const shownAt = useRef(Date.now())
  const [status, setStatus] = useState<Status>('idle')
  const [error, setError] = useState('')

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const data = new FormData(event.currentTarget)
    data.set('_elapsed', String(Date.now() - shownAt.current))
    setStatus('sending')
    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: { Accept: 'application/json' },
        body: data,
      })
      const result = await response.json()
      if (result.ok) {
        setStatus('sent')
      } else {
        setError(result.message)
        setStatus('error')
      }
    } catch {
      setError('Something went wrong. Please try again.')
      setStatus('error')
    }
  }

  return { status, error, onSubmit }
}
```

A contact form then needs only its markup:

```tsx title="ContactForm.tsx"
import { useFormBackend } from './useFormBackend'

export function ContactForm() {
  const { status, error, onSubmit } = useFormBackend('https://api.nisuform.com/s/YOUR_FORM_KEY')

  if (status === 'sent') {
    return <p role="status">Thanks! We got your message.</p>
  }

  return (
    <form onSubmit={onSubmit}>
      <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={status === 'sending'}>
        {status === 'sending' ? 'Sending...' : 'Send'}
      </button>
      {status === 'error' && <p role="alert">{error}</p>}
    </form>
  )
}
```

The inputs are uncontrolled, so a failed submission leaves the visitor's text in place. `_gotcha` is a honeypot. People never see it, and bots that fill it in are dropped. Because the hook sends `FormData`, a file input works as well on Pro. Add `<input type="file" name="attachment" />` and the file goes with the rest of the fields.

To try it, [create a form](https://app.nisuform.com) and swap `YOUR_FORM_KEY` for the key on its **Setup** tab. The same tab has a component with your own field names under **Code**. Wait two seconds before you send a test, because anything faster is treated as a bot.



## Errors you can show

A rejected submission comes back with `ok: false`, an `error` code and a `message` written for visitors. These are the ones React apps run into most:

| `error`              | Why                                                                                      | Fix                                                         |
| -------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| `origin_not_allowed` | The page isn't in the form's allowed websites                                            | Add the site, and `http://localhost:5173` while you develop |
| `rate_limited`       | More than 5 submissions a minute from one IP address to one form, 50 on Pro and Business | Wait a minute between tests                                 |
| `captcha_failed`     | Turnstile or reCAPTCHA is on and the token is missing                                    | Render the widget inside the form                           |
| `quota_exceeded`     | The team used its monthly submissions                                                    | Upgrade, or wait for next month                             |

The honeypot and the fill-time check answer `ok: true` on purpose, so bots can't tell they were caught. The [error reference](/docs/reference/errors) lists every code.

## Where it runs

The hook uses `fetch`, `FormData` and React state, so it works anywhere React does. In a Vite or React Router app, import the component like any other. In Next.js, add `'use client'` to the top of the file, as the [Next.js page](/form-backend/nextjs) shows. In Astro, render it as an island with `client:load`. Lovable, Bolt and v0 build React apps too, and the [Lovable page](/form-backend/lovable) has a prompt that makes this change for you.

## Guides

- [React form component](https://nisuform.com/docs/frameworks/react)
- [React contact form: send email without a backend](https://nisuform.com/blog/react-contact-form)
- [Submit a form with fetch](https://nisuform.com/docs/connect/javascript)

## Frequently asked questions

### Can a React app send email without a backend?

No. React runs in the browser, and a browser can't send email. Either your own server calls an email provider, or the form posts to a form backend that stores the submission and emails you. With a form backend, the React app stays a static site.

### Is it safe to put the form endpoint in my React code?

Yes. The endpoint only accepts new submissions. Nobody can read your submissions or change the form with it, so it doesn't need to be secret. To stop other sites from posting to it, add your domains to the form's allowed websites.

### Does it work with React Hook Form, Formik or TanStack Form?

Yes. Let the library handle fields and validation, and send its values to the endpoint from its submit handler with Accept set to application/json. Show an error when the response has ok set to false.

### Do I need CORS headers or a proxy?

No. The endpoint accepts requests from any origin, so a React app on localhost, Vercel, Netlify, Cloudflare Pages or GitHub Pages can post to it directly.

### How much does it cost?

Free covers 100 submissions a month across 3 forms, with spam filtering, email notifications and the inbox. Pro is $10 a month for 5,000 submissions, unlimited forms, file uploads, webhooks and auto-replies.
