How to add a contact form to a React app (no backend required)
React is great at rendering a form. It is not, on its own, a way to do anything with what gets typed into it. onSubmit fires, you have an object full of strings, and then — nothing. No server to POST to, no database to write to, no mail API to call. If your React app is a Vite SPA, a Create React App leftover, or anything else that isn't already running its own backend, you've hit the same wall everyone hits: the form works, and the submission goes nowhere.
You could stand up an Express server, wire up a database table, add a mail provider, and write spam filtering — congratulations, you've built a small backend just to receive a contact form. Or you can POST straight to a hosted ingestion endpoint and skip all of it. This guide shows the second approach with SubmissionBuddy, using plain, modern React: function components, useState, and fetch. No form library, no SDK — SubmissionBuddy doesn't ship one, and that's deliberate. There's nothing to install.
The complete component
import { useState } from 'react'
const ENDPOINT = 'https://api.submissionbuddy.io/f/your-form'
export default function ContactForm() {
const [fields, setFields] = useState({ name: '', email: '', message: '' })
const [status, setStatus] = useState('idle') // idle | submitting | success | error
function handleChange(event) {
const { name, value } = event.target
setFields((prev) => ({ ...prev, [name]: value }))
}
async function handleSubmit(event) {
event.preventDefault()
setStatus('submitting')
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
// 202 Accepted: { "ok": true, "submission_id": "…" }
setStatus('success')
setFields({ name: '', email: '', message: '' })
} catch (err) {
setStatus('error')
}
}
if (status === 'success') {
return <p role="status">Thanks — we got your message and will get back to you soon.</p>
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input type="text" name="name" value={fields.name} onChange={handleChange} required />
</label>
<label>
Email
<input type="email" name="email" value={fields.email} onChange={handleChange} required />
</label>
<label>
Message
<textarea name="message" value={fields.message} onChange={handleChange} required />
</label>
{/* honeypot: hidden from real users, bots that fill every field get silently dropped */}
<input
type="text"
name="_honeypot"
value={fields._honeypot || ''}
onChange={handleChange}
style={{ display: 'none' }}
tabIndex={-1}
autoComplete="off"
/>
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send'}
</button>
{status === 'error' && <p role="alert">Something went wrong. Please try again.</p>}
</form>
)
}
That's the whole thing — one component, useState for the field values and the submission status, fetch for the POST. No context provider, no reducer, no dependency to add to package.json.
Why _honeypot and not a CAPTCHA widget
The hidden _honeypot field above is read by the endpoint as a spam signal, not a real field — it's stripped before the submission is stored. A human never sees or fills it (it's display: none and pulled out of tab order); a bot filling every input in the DOM will fill it too. When it's filled, SubmissionBuddy silently drops the submission — no S3 write, no email, no dashboard entry — but still returns a normal-looking success response, so the bot never learns it failed. This is included on every plan, free included, and needs zero configuration beyond adding the field.
If you're fighting more persistent bots, paid plans (Pro and up) add an invisible, self-hosted proof-of-work CAPTCHA on top — no third-party widget, no cookies, nothing for the visitor to click.
Success and error handling instead of a redirect
Because this is a React SPA and not a full page reload, the pattern above uses status state rather than _redirect. If you'd rather send the visitor to a dedicated /thanks page after a successful submit, add _redirect as a field in the JSON body and check for a 303 response instead of 202 — though for a single-page app, swapping in a success message like the one above is usually the better fit, since there's no page navigation to interrupt.
What happens after the fetch resolves
The 202 you get back means the raw submission has already been written to durable storage — that write happens before the response goes out, not after. From there it moves through independent, retryable queue stages: a worker writes it into the database that backs your dashboard, and (if the form has notification recipients configured) another sends an email. If a stage hiccups, it retries automatically instead of silently dropping your lead.
What this costs
Nothing, to start. The free plan gives you 100 submissions a month across 2 forms, with no credit card required — plenty for a portfolio site or a small project's contact page. If you outgrow that, Pro and Business plans raise the ceiling and add CAPTCHA, more forms, and (on Business) auto-responses and team access. Sign up and swap your-form above for your real endpoint slug.