React
Build a React contact form that emails you every submission, with loading, success and error states and no server code.
A React app built with Vite is a bundle of static files, so there is nowhere to put the server code a contact form normally needs. SilentForms takes that part over: the component posts the fields from the browser and each submission is emailed to you. No API route, no email provider, no secrets in the bundle.
What you'll build#
A ContactForm component for React 19 that:
- reads the fields with
FormDatainstead of tracking every keystroke in state, - shows a sending state, a success message and the endpoint's own error messages,
- announces the result to screen readers,
- keeps the hidden honeypot field.
The same component works in React Router apps and anything else that renders React in the browser; only the environment variable syntax changes. For Next.js, see the Next.js guide.
1. Get an access key#
Enter your email on the access key page and confirm it. The key only decides whose inbox a submission goes to, so it is fine for it to end up in the JavaScript bundle.
2. Add the key to your environment#
Create .env.local in the project root:
VITE_SILENTFORMS_KEY=YOUR_ACCESS_KEY_HEREVite only exposes variables that start with VITE_ to client code, as import.meta.env.VITE_SILENTFORMS_KEY, and replaces them at build time. Restart npm run dev after editing the file, and define the same variable in your host's build settings, because .env.local is git-ignored and never reaches the build server.
3. The component#
Create src/ContactForm.jsx:
import { useState } from 'react';
const ACCESS_KEY = import.meta.env.VITE_SILENTFORMS_KEY;
export default function ContactForm() {
const [status, setStatus] = useState('idle'); // idle | sending | sent | error
const [message, setMessage] = useState('');
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
data.append('accessKey', ACCESS_KEY);
setStatus('sending');
setMessage('');
try {
const response = await fetch('https://silentforms.com/api/submit', {
method: 'POST',
headers: { Accept: 'application/json' },
body: data,
});
const result = await response.json();
if (!response.ok) throw new Error(result.message);
form.reset();
setStatus('sent');
setMessage('Thanks! Your message has been sent.');
} catch (error) {
setStatus('error');
setMessage(error.message || 'Something went wrong. Please try again.');
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="cf-name">Name</label>
<input id="cf-name" name="name" type="text" autoComplete="name" required />
<label htmlFor="cf-email">Email</label>
<input id="cf-email" name="email" type="email" autoComplete="email" required />
<label htmlFor="cf-message">Message</label>
<textarea id="cf-message" name="message" rows={5} required />
<input type="checkbox" name="botcheck" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send message'}
</button>
<p role={status === 'error' ? 'alert' : 'status'}>{message}</p>
</form>
);
}Render it anywhere, for example in src/App.jsx:
import ContactForm from './ContactForm';
export default function App() {
return (
<main>
<h1>Contact</h1>
<ContactForm />
</main>
);
}Why uncontrolled inputs#
A contact form only needs its values once, on submit. Leaving the inputs uncontrolled and reading them with new FormData(form) avoids a useState and an onChange per field, and the browser's own required and type="email" checks still run before onSubmit fires. Switch to controlled inputs only when you need the values while the visitor types, for live validation or a character counter, and then build the request body from your state instead of FormData.
Sending FormData as the body posts it as multipart/form-data, which SilentForms accepts. The Accept: application/json header is what makes the endpoint answer with JSON instead of a redirect.
Accessible status messages#
The status paragraph is always rendered, even when empty. Live regions are announced when their content changes, and some screen readers miss a region that is inserted at the same moment as its text. role="status" is read politely after the current announcement; role="alert" interrupts, which suits errors.
Using a React 19 form action#
React 19 lets a form take a function as its action. With useActionState you get the result and a pending flag without managing them yourself:
import { useActionState } from 'react';
async function send(previousState, formData) {
formData.append('accessKey', import.meta.env.VITE_SILENTFORMS_KEY);
const response = await fetch('https://silentforms.com/api/submit', {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
const result = await response.json();
return { ok: response.ok, message: response.ok ? 'Thanks! Your message has been sent.' : result.message };
}
export default function ContactForm() {
const [state, formAction, isPending] = useActionState(send, null);
return (
<form action={formAction}>
<label htmlFor="cf-email">Email</label>
<input id="cf-email" name="email" type="email" required />
<label htmlFor="cf-message">Message</label>
<textarea id="cf-message" name="message" required />
<input type="checkbox" name="botcheck" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<button type="submit" disabled={isPending}>{isPending ? 'Sending…' : 'Send'}</button>
<p role={state && !state.ok ? 'alert' : 'status'}>{state?.message}</p>
</form>
);
}React resets uncontrolled fields after a function action completes, including after an error, so the visitor loses what they typed if the request fails. If that matters, use the onSubmit version above, which only resets on success.
Without JavaScript#
If the form must work before the bundle loads, make it a plain HTML form with action="https://silentforms.com/api/submit", method="POST" and a hidden accessKey input. The browser follows a redirect after posting; see Redirect after submit. In a single-page app that is rendered entirely by JavaScript there is no "before", so the fetch version is usually the right choice.
Testing#
- Run
npm run dev, submit the form and check your inbox (and spam folder the first time). - Check the error path: temporarily change the key in
.env.localtoinvalidand restart. The message under the button should read "Invalid or inactive access key." - Run
npm run build && npm run previewto check that the key was inlined into the production bundle. If the form reports "Access key is required.", the variable was missing at build time.
Next steps#
- Prefer schema-driven validation? Use the React Hook Form guide.
- Add Cloudflare Turnstile if bots get past the honeypot.
- Forward submissions to your own tools with webhooks.