Back to blog

August 26, 2026 · NBForms Team

React Form Submission Without a Server

Most React form tutorials assume a form library and a backend to submit to. This one needs neither — plus what actually happens when a browser POSTs straight to a third-party API.

Search "React contact form" and the results mostly agree on the shape of the answer before they've even discussed where the data goes: import a form library, wire up a schema validator, then — almost as an afterthought — send the values somewhere. The form-library part is optional. For a form this size, sending FormData straight from the DOM works, and skipping the library isn't a shortcut so much as the more direct path.

A computer monitor displaying lines of code Photo by Chris Ried on Unsplash

Uncontrolled, on purpose

React's default instinct is a controlled form: a value and an onChange on every field, state that re-renders on each keystroke, a submit handler that reads from that state instead of the DOM. That pattern earns its cost when a form needs live validation feedback, a running character count, or fields that react to each other. A contact form asking for a name, an email, and a message doesn't need any of that — nothing reads a field's value until the moment of submit, so there's nothing gained by tracking it on every keystroke in between.

new FormData(e.target) reads the form element directly at submit time, which is what makes the uncontrolled version workable: every named input, textarea, and select on the form is collected in one call, with no useState per field and no risk of a field's local state and its DOM value drifting out of sync. Adding a field is a JSX change; nothing else has to know about it.

The whole component

import { useState } from 'react'

export default function ContactForm() {
  const [status, setStatus] = useState('idle')

  async function handleSubmit(e) {
    e.preventDefault()
    setStatus('sending')
    const res = await fetch('https://api.nbforms.com', {
      method: 'POST',
      body: new FormData(e.target),
    })
    const data = await res.json()
    setStatus(res.ok ? 'success' : 'error')
  }

  if (status === 'success') return <p>Thanks — we'll be in touch!</p>

  return (
    <form onSubmit={handleSubmit}>
      <input type="hidden" name="_token" value="YOUR_TOKEN" />
      <label>Name</label>
      <input type="text" name="name" required />
      <label>Email</label>
      <input type="email" name="email" required />
      <label>Message</label>
      <textarea name="message" required />
      <button type="submit" disabled={status === 'sending'}>
        {status === 'sending' ? 'Sending…' : 'Send'}
      </button>
    </form>
  )
}

The _token hidden field ties the request to a specific NBForms form — generated once from that form's setup page — and every other field name becomes a column in the submissions dashboard the first time it shows up in a request, with nothing to configure ahead of time on NBForms' side. status alone is enough state to cover loading, success, and error, because nothing else in this component needs to react to individual field values.

If a form does need controlled state

Sometimes the controlled version really is the right call — a character-limited message field with a live counter, an email that's validated as it's typed, a submit button that's disabled until every field passes. Reaching for controlled state doesn't mean giving up on skipping a form library, and it doesn't mean fighting FormData to build it field by field either. NBForms' endpoint accepts a JSON body just as readily as FormData, so the natural pairing for controlled state is sending the state object directly:

const [name, setName]       = useState('')
const [email, setEmail]     = useState('')
const [message, setMessage] = useState('')

async function handleSubmit(e) {
  e.preventDefault()
  await fetch('https://api.nbforms.com', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ _token: 'YOUR_TOKEN', name, email, message }),
  })
}

Same endpoint, same dashboard, same field-name-becomes-a-column behavior — the only difference is which shape of body matches how the data is already being held in state. Neither approach is more "correct"; which one to reach for depends on whether the form actually needs to read its own values before submit, not on anything the endpoint requires.

The whole app can stay static

Nothing in either version above assumes a server exists anywhere in the deployment. A Vite or Create React App project builds to a folder of static files — npm run build, then a dist/ or build/ directory — that a static host like Netlify, GitHub Pages, Cloudflare Pages, or a plain S3 bucket can serve with no Node process running behind it at all. That's a stronger claim than "no API route needed": there's no server anywhere in the stack for a request to hit, not a serverless function, not a long-running process, not even the lightweight edge runtime some meta-frameworks provide by default. The form works identically whether the page it's on is served from a CDN or a full Node deployment, because nothing about submitting it was ever server-dependent in the first place.

No proxy, no CORS wall

A client-only React app has no server of its own to route a request through, which raises the obvious question: does a browser calling https://api.nbforms.com directly from localhost:5173 or a deployed static domain just get blocked by CORS? It doesn't — the API is configured to accept cross-origin requests from any origin, precisely because it exists to be called this way, straight from a browser with no backend behind it. There's no local dev proxy to configure in vite.config.js or a package.json "proxy" field, and no rewrite rule needed on whatever static host serves the built app.

If a form library is already in the app

None of this is an argument against React Hook Form or Formik in a codebase that already uses one elsewhere — it's specifically about what a form this small needs on its own. If a form is already wired up with one of those libraries for other reasons (a multi-step flow, cross-field validation, an existing shared form component), the values it collects work exactly the same way once submit is reached: read them out through the library's own API — getValues() in React Hook Form, the values object Formik's render props expose — build a FormData or plain object from them, and fetch it to the same endpoint. The library owns collecting and validating the data; where that data goes afterward is a separate decision either way.

What it doesn't do

Same honesty that applies to any lightweight form backend: this stores a structured submission, filters it for spam automatically, and emails an alert — it isn't a place to put business logic. No payment processing, no multi-step conditional wizard, no automatic reply sent back to whoever filled it in (an owner alert and whatever success UI the component shows, as above, is the whole of it). A form that genuinely needs to trigger something specific to the app — write to a database in a custom shape, kick off an internal workflow — still calls for real server code somewhere; a contact or inquiry form usually doesn't. Spam filtering, the submissions dashboard, and email alerts all run identically no matter which of the two request shapes above sent the data — none of that behavior is tied to FormData specifically, so switching between the two later is a one-line change, not a rewrite.

For the App Router / Server Action side of this same pattern, and two fetch-specific gotchas (a redirect setting that silently breaks JSON parsing, and a Content-Type header mistake with FormData) covered in full rather than repeated here, see the Next.js form backend post. The React snippet reference has the code above with no prose around it, for anyone who just wants to copy it in.

Frequently asked questions

Should I still set a redirect URL in my form's settings for a form like this?

No, leave it blank — a fetch call follows redirects transparently and would receive HTML back where the code expects JSON. This applies to any fetch-based submission, not just React, and is covered in full in the Next.js form backend post.

Does React 18 Strict Mode cause a submission to fire twice in development?

No. Strict Mode double-invokes render, effects, and a few lifecycle methods to surface side-effect bugs, but it doesn't double-invoke event handlers — a submit only fires once per actual click, in development and production alike.

Do I need to install anything for this to work?

No packages at all. fetch and FormData are browser APIs, already available in any React setup — Create React App, Vite, or a hand-rolled bundler config.

Will the browser block the request as cross-origin?

No — NBForms' API is configured to accept requests from any origin, so a client-only React app calling it directly from the browser doesn't need a proxy, a rewrite rule, or any CORS workaround.

Can this coexist with a form built using React Hook Form or Formik?

Yes. Both libraries expose the collected values through their own APIs (getValues(), values in Formik's render props); build a FormData or plain object from that and POST it the same way — the endpoint doesn't care which library assembled the data.