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