You added data-netlify="true" to your form, deployed your React app, and… nothing. Netlify forms not working — no submissions in the dashboard. Maybe a 404 error. Maybe the form shows up but submissions silently disappear.
You’re not doing anything wrong. This is a well-documented architectural problem between Netlify Forms and every JavaScript framework that renders forms at runtime — React, Next.js, Gatsby, Remix, all of them.
Here’s what’s actually going on and how to deal with it.
The Problem
Netlify Forms was designed for static HTML sites. When you deploy to Netlify, a post-processing bot scans your generated HTML files looking for <form> tags with the data-netlify="true" attribute. It registers those forms in its backend so it knows where to route submissions.
The problem: React doesn’t put forms in the HTML.
When you build a React app (whether it’s Vite, Create React App, or any SPA), the output is a near-empty index.html with a <div id="root"></div> and some JavaScript bundles. Your form component exists in JavaScript, not in the HTML. Netlify’s bot scans the HTML, finds no forms, and moves on.
When a user submits the form at runtime, Netlify’s backend has no record of it. The result is a 404 error or a submission that vanishes.
Why This Happens
Netlify Forms operates in two completely separate phases from React:
Build time: Netlify runs your build command, scans the output HTML for form tags, and registers them. For a React SPA, there are no form tags in the HTML — they only exist inside your JavaScript bundles.
Runtime: React hydrates in the browser and renders the form. A user fills it out and hits submit. But Netlify’s form backend was never told this form exists. The submission has nowhere to go.
This isn’t a bug — it’s a fundamental mismatch. Netlify Forms is an HTML parser. React is a JavaScript renderer. They operate in different phases and never talk to each other.
For SSR frameworks like Next.js and Gatsby, there’s a second issue. Netlify’s post-processor modifies the rendered HTML: it strips data-netlify attributes and injects a hidden <input name="form-name"> field. When React hydrates on the client, the DOM doesn’t match what React expects, causing hydration errors — those cryptic “initial UI does not match what was rendered on the server” warnings in your console.
How to Fix It (The Workaround)
The official workaround is to create a hidden HTML form that acts as a “body double” for Netlify’s bot to find. Then your React form submits to that registered endpoint.
Step 1: Add a hidden form to your static HTML
In your public/index.html (or a separate HTML file in /public), add a form with the same name and field name attributes as your React form:
<!-- public/index.html (inside <body>, before <div id="root">) -->
<form name="contact" netlify netlify-honeypot="bot-field" hidden>
<input name="name" />
<input name="email" />
<textarea name="message"></textarea>
</form>
The hidden attribute keeps it invisible. The netlify-honeypot attribute adds basic spam protection.
Step 2: Submit via fetch in your React component
In your React form, submit the data as URL-encoded form data (not JSON — Netlify won’t accept JSON):
function ContactForm() {
const handleSubmit = async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
formData.append("form-name", "contact");
const response = await fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(formData).toString(),
});
if (response.ok) {
alert("Thanks for your message!");
}
};
return (
<form name="contact" onSubmit={handleSubmit}>
<input type="hidden" name="form-name" value="contact" />
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit">Send</button>
</form>
);
}
The catch
This works, but it’s fragile:
- You must keep two forms in sync. If you add a field to your React form but forget the HTML duplicate, that field’s data is silently dropped. No error, no warning.
- Dynamic fields don’t work. If your form has conditional fields (shown after a dropdown selection, inside a modal, etc.), you can’t represent those in static HTML.
- No local testing.
netlify devdoesn’t support form submissions — you have to push to a preview deploy every time you want to test. - URL-encoded only. You must encode the body as
application/x-www-form-urlencoded. Sending JSON (the natural choice in React) silently fails.
This workaround has existed since 2017. The Netlify support forums have hundreds of threads from developers hitting this exact issue — enough that Netlify created a dedicated support guide for it.
It works. But maintaining a hidden HTML shadow copy of every form isn’t what you signed up for when you chose a “zero-config” platform.
A Simpler Approach
On ZeroDeploy, forms work the way you’d expect them to in a React app. No build-time HTML scanning. No hidden form duplicates.
You POST form data to /_forms/<name> and it just works — with JSON:
function ContactForm() {
const handleSubmit = async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
const response = await fetch("/_forms/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.success) {
alert("Thanks for your message!");
}
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<button type="submit">Send</button>
</form>
);
}
No hidden HTML forms. No URL-encoding. No keeping two copies in sync. Forms are auto-created on first submission, so there’s nothing to configure on the platform side either.
It also works with plain HTML forms (no JavaScript needed) — set the action to /_forms/contact and method to POST, and ZeroDeploy handles the redirect.
Both approaches include built-in rate limiting and honeypot spam protection. You can view submissions, export to CSV, and set up email notifications from the CLI or dashboard. For a broader look at all your options, see How to Add a Contact Form to a Static Site.
Frequently Asked Questions
Why do Netlify forms return 404 in React?
Netlify Forms scans static HTML at build time to register forms. React apps render forms in JavaScript at runtime, so there’s no form in the HTML for Netlify’s bot to find. When a user submits, Netlify’s backend has no record of the form and returns a 404.
Do Netlify forms work with React SPAs?
Not out of the box. You need a workaround: add a hidden HTML form with matching field names in your public/index.html so Netlify’s build-time bot can detect it, then submit via fetch from your React component using URL-encoded data (not JSON).
Can Netlify forms handle JavaScript-rendered forms?
No. Netlify Forms only detects forms present in static HTML at build time. Forms rendered by JavaScript frameworks like React, Vue, or Svelte are invisible to the Netlify post-processing bot. You must create a hidden HTML duplicate for each form.
Wrap Up
Netlify Forms was built for a world of static HTML. If your site generates HTML at build time and your forms live in that HTML, it works well. But if you’re building with React — or any framework that renders forms in JavaScript — you’re working against the architecture.
The hidden form workaround gets the job done, but it adds a maintenance burden that shouldn’t be necessary in 2026.
If you’re starting a new project or tired of the shadow-form dance, ZeroDeploy’s form handling is designed for how modern apps actually work. Deploy your site, POST to an endpoint, done.
If you’re evaluating alternatives, see our Netlify vs ZeroDeploy comparison for a full breakdown, or compare Netlify Forms pricing and limits specifically. Jump straight to the quickstart guide to deploy a React app with working forms in under 5 minutes.