How to handle form submissions on Netlify without a backend
Add data-netlify="true" to a form and Netlify stores submissions, filters spam, and sends notifications. Plus the static skeleton file that framework forms need.
Key takeaways
Add data-netlify="true" to your <form> tag, enable form detection once in the Netlify UI, and deploy. Netlify parses your built HTML at deploy time, registers the form, stores submissions, filters spam through Akismet, and can email or webhook you on each one. No function to write and no backend to run.
- Detection happens by parsing built HTML at deploy time. There’s no runtime API call.
- JS-rendered and SSR forms are not in the built HTML, so they need a static skeleton file at
public/__forms.htmlwith exactly-matching field names. - In SSR apps, POST to
/__forms.html, not/. The SSR catch-all intercepts/and the submission never reaches form processing. - AJAX bodies must be URL-encoded. JSON is not supported.
- A missing submission is usually Akismet, not a bug. Check the Spam list first.
- Custom success paths must be extensionless.
/thank-you, not/thank-you.html.
The form that doesn’t need a server
A contact form is the smallest possible reason to stand up a backend, and for a long time it was the most common one. Netlify Forms removes that reason. You write ordinary HTML, add one attribute, and submissions land in a dashboard with spam filtering and notifications already attached.
The static case is genuinely one attribute. The framework case has one extra file, and that file is where nearly all the confusion lives, so it gets its own section below. If your form is silently doing nothing, skip ahead to the failure modes.
First, enable it once: Forms > Enable form detection in the Netlify UI. It takes effect on your next deploy.
When this fits, and when it doesn’t
Netlify Forms suits contact forms, lead capture, newsletter signups, file-upload forms, and anything else where you want submissions recorded and forwarded rather than processed.
Reach for something else when:
- You need to act on the submission programmatically. Write a function instead, or use the
formSubmittedplatform event to react to a Netlify Forms submission. See How to add a backend API endpoint with Netlify Functions. - The data belongs in your own database. Forms stores submissions in Netlify’s database, reachable only through the UI, API, or CSV export.
- You’re handling PII at volume. PII uploads need extra security through the Very Good Security integration, and you should be exporting and deleting data regularly.
Worked example
Static HTML
<form name="contact" method="POST" data-netlify="true"> <p><label>Your Name: <input type="text" name="name" /></label></p> <p><label>Your Email: <input type="email" name="email" /></label></p> <p><label>Message: <textarea name="message"></textarea></label></p> <p><button type="submit">Send</button></p></form>The name attribute sets the form name in the UI and must be unique per site. At deploy time Netlify strips the data-netlify attribute and injects <input type="hidden" name="form-name" value="contact" /> for you.
Include an <input name="email"> so the notification email’s Reply-to is set to whoever submitted.
Framework forms: the static skeleton file
Next.js, Nuxt, SvelteKit, Astro, and Gatsby forms aren’t in the built HTML, so deploy-time parsing never sees them. Two pieces are required.
First, a hidden copy of each form at public/__forms.html:
<form name="pizzaOrder" data-netlify="true" hidden> <input type="hidden" name="form-name" value="pizzaOrder" /> <input name="order" type="text" /></form>Every field the component submits has to appear here, with names matching exactly. Netlify validates field names against the registered form, so a mismatch means silent failure.
Second, the rendered form carries a matching hidden form-name input:
<form name="pizzaOrder" method="post" data-netlify="true" onSubmit={handleSubmit}> <input type="hidden" name="form-name" value="pizzaOrder" /> <input name="order" type="text" onChange={handleChange} /> <input type="submit" /></form>AJAX submission
const handleSubmit = event => { event.preventDefault(); const formData = new FormData(event.target); fetch("/__forms.html", { // static sites may POST to "/"; SSR must target the skeleton file method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(formData).toString() }) .then(() => alert("Thank you for your submission")) // or navigate("/thank-you") .catch(error => alert(error));};document.querySelector("form").addEventListener("submit", handleSubmit);Three requirements here. The body must be URL-encoded; JSON is not supported. If your rendered form has no hidden form-name input, you must include a form-name field in the POST body. And the honeypot field name plus g-recaptcha-response, if you use it, have to be in the body, which FormData() handles automatically.
File uploads
Add type="file", and optionally enctype="multipart/form-data" on the form. For AJAX file uploads, do not set a Content-Type header and let the browser set it with the multipart boundary:
document.forms.fileForm.addEventListener("submit", event => { event.preventDefault(); fetch("/", { body: new FormData(event.target), method: "POST" }) // no headers .then(() => { /* success */ });});One file per field, so use multiple fields for multiple files. Maximum request size is 8 MB with a 30-second upload timeout. After you delete a form, uploaded files remain at their direct URL for 24 hours.
Custom success page
<form name="contact" action="/thank-you" method="POST" data-netlify="true"></form>The action path is relative to your site root and starts with /. Use extensionless paths. Netlify serves thank-you.html at /thank-you, and the .html path returns a 404. A custom success alert rather than a page is only possible via AJAX, where you substitute your own logic for the redirect.
Spam prevention
Every submission is filtered by Akismet. Passing submissions go to Verified submissions, flagged ones to Spam submissions. Honeypot and reCAPTCHA failures are rejected outright and appear in neither list.
A honeypot is the lightest option:
<form name="contact" method="POST" netlify-honeypot="bot-field" data-netlify="true"> <p class="hidden"><label>Don't fill this out: <input name="bot-field" /></label></p> <!-- real fields --></form>Add netlify-honeypot="bot-field" to the form and include a CSS-hidden field of that name. Any value entered means the submission is quietly rejected.
For Netlify-provided reCAPTCHA 2, add data-netlify-recaptcha="true" to the <form> and an empty <div data-netlify-recaptcha="true"></div> where it should render. Only one Netlify-provided challenge per page is allowed; for multiple, use custom reCAPTCHA. For JS-rendered forms, add the div to the static skeleton file too.
Custom reCAPTCHA 2 means your own snippet plus data-netlify-recaptcha="true" on the form, with two env vars: SITE_RECAPTCHA_KEY (scopes Builds and Runtime) and SITE_RECAPTCHA_SECRET (scope Runtime).
Notifications and subject lines
Default sender is formresponses@netlify.com. Set the subject with a hidden input or in the Netlify UI, but not both, because the HTML value always overrides the UI.
<input type="hidden" name="subject" value="New lead from %{formName} (%{submissionId})" />Available variables: %{formName}, %{siteName}, %{submissionId}. Forms created before May 5, 2023 carry a [Netlify] subject prefix, which you remove by adding the data-remove-prefix attribute to the subject input.
Set up email, webhook, or Slack notifications under Configuration > Notifications > Form submission notifications > Add notification.
Common failure modes
A legitimate submission is missing. First suspect Akismet. A missing real submission is usually spam-flagged. Check the Spam list, or the API with ?state=spam, and mark it verified. Don’t build a custom recovery function or disable spam filtering as a first move.
Your own test submissions keep getting flagged. Test submissions look like spam. Use a real email address rather than test@test.com, write full sentences, and don’t hammer from one IP.
No submissions at all, ever. Confirm form detection is enabled in the UI, then redeploy. It only takes effect on the next deploy.
Your framework form silently does nothing. Either the static skeleton file is missing, its field names don’t match exactly, or your AJAX is targeting the wrong path. In SSR apps, fetch("/") is intercepted by the SSR catch-all function and never reaches form processing. POST to /__forms.html itself.
Your Astro form was never registered. Routes with export const prerender = false, or under output: "server", are never scanned at build time. Put the form on a prerendered page, or rely on the static skeleton file.
Your Next.js form broke after upgrading. On Next.js Runtime v5 (Next 13.5+), extract form definitions to the static skeleton file and submit via AJAX rather than full-page navigation.
Your AJAX submission returns an error. Check the body encoding. It must be URL-encoded; JSON is not supported. And for file uploads, make sure you’re not setting a Content-Type header.
Your success page 404s. You used /thank-you.html. Use the extensionless /thank-you.
Old field data vanished from the UI. The UI shows only fields from the last deployed form version. Mark old fields hidden instead of removing them to keep them visible. The old data is still available via listFormSubmissions.
Your API script only sees the first page of submissions. Page through results using the Link header. Code that reads only the first response silently drops the rest.
The submission summary shows the wrong field as the title. The summary is derived from field type, not name, so field order in your HTML matters. The title is the first non-hidden text <input> that isn’t email-like (type="email", or a name matching email, mail, from, twitter, or sender), falling back to a field named title or subject. The body is the first <textarea>.
You deleted a form and want it back. You can’t. Deleting a form is permanent: future submissions return 404 and past submissions become unavailable. Export CSV first.
Reference
Form attributes
| Attribute | Effect |
|---|---|
data-netlify="true" | Marks the form for detection. The bare netlify attribute is equivalent. |
name | Form name in the UI. Must be unique per site. |
action="/path" | Custom success page. Extensionless, starting with /. |
netlify-honeypot="bot-field" | Enables a honeypot on the named hidden field |
data-netlify-recaptcha="true" | Netlify reCAPTCHA 2. Also needs an empty div with the same attribute. |
enctype="multipart/form-data" | Optional, for file uploads |
Hidden inputs
| Input | Purpose |
|---|---|
form-name | Injected automatically for static forms. Required manually in JS-rendered forms and in AJAX bodies. |
subject | Sets the notification subject. Overrides the UI setting. |
data-remove-prefix (attribute on subject) | Removes the legacy [Netlify] prefix on pre-May-2023 forms |
Subject-line variables
%{formName}, %{siteName}, %{submissionId}
File upload limits
| Limit | Value |
|---|---|
| Files per field | 1 (use multiple fields) |
| Max request size | 8 MB |
| Upload timeout | 30 s |
| File retention after form deletion | 24 h at the direct URL |
Custom reCAPTCHA env vars
| Variable | Scopes |
|---|---|
SITE_RECAPTCHA_KEY | Builds + Runtime |
SITE_RECAPTCHA_SECRET | Runtime |
Reading submissions via the API
Use only documented surfaces. Page through with the Link header. Query spam with ?state=spam. Note that listFormSubmissions returns data from old and removed fields that the UI no longer shows.
Constraints
- Deleting a form is permanent. Export CSV first.
- Submitted code is sanitized:
<script>becomes escaped entities. - Data lives in Netlify’s database, reachable only via UI, API, or CSV.
- For PII, export and delete regularly.
FAQs
How do I add a contact form to a Netlify site?
Add data-netlify="true" to your <form> tag with a unique name, enable form detection in the Netlify UI under Forms, and deploy. Netlify detects the form by parsing your built HTML and stores submissions in the dashboard.
Why isn’t my Netlify form working in Next.js, Nuxt, or SvelteKit?
JS-rendered and SSR forms aren’t present in the built HTML, so they’re never detected. Create public/__forms.html with a hidden copy of each form, matching field names exactly, and POST your AJAX submission to /__forms.html.
Why do I need to POST to /__forms.html instead of /?
In SSR apps the SSR catch-all function intercepts /, so the submission never reaches Netlify’s form processing. Targeting the skeleton file directly avoids that.
Can I submit a Netlify form as JSON?
No. The body must be URL-encoded (application/x-www-form-urlencoded). For file uploads, send FormData with no Content-Type header at all.
Where did my form submission go?
Most likely the Spam list. Every submission passes through Akismet, and legitimate submissions do get false-positived, especially test ones. Check the Spam list or query the API with ?state=spam and mark it verified.
How do I add a custom thank-you page?
Set action="/thank-you" on the form. Use the extensionless path; /thank-you.html returns a 404.
How do I stop form spam?
Akismet filtering is automatic. Add a honeypot with netlify-honeypot="bot-field" plus a CSS-hidden field of that name, or add reCAPTCHA 2 with data-netlify-recaptcha="true" on the form and an empty matching div.
Can visitors upload files through a Netlify form?
Yes. Add type="file" inputs, one file per field, up to 8 MB per request with a 30-second timeout.
Can I read form submissions programmatically?
Yes, via the documented listFormSubmissions API. Page through results using the Link header or you’ll silently miss submissions.
Related
This article is generated from Netlify’s open-source agent guidance at netlify/context-and-tools, the same reference our AI coding agents use.
Need a form on your site? Start at netlify.new.