Serverless form handling on Netlify-hosted sites — detects HTML forms at deploy time, stores submissions, filters spam, and sends notifications. Use when adding a contact form, lead-capture form, file-upload form, or newsletter signup to a Netlify site; wiring AJAX form submission; setting up a custom thank-you page; adding a honeypot or reCAPTCHA to a form; getting forms working in Next.js, Nuxt, SvelteKit, Astro, or Gatsby; reading form submissions via the Netlify API; or debugging missing submissions and forms that silently fail to register.
74
91%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Mark a form for detection with data-netlify="true" (or the bare netlify attribute — equivalent) on the <form> tag. Forms are detected by parsing the final built HTML at deploy time — there is no runtime API call or backend code. Client-side/JS-rendered/SSR forms are NOT in the built HTML and are never detected on their own; they require a static skeleton file (see below).
Prerequisite: form detection must be enabled once in the Netlify UI (Forms > Enable form detection). Takes effect on the next deploy.
<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>name sets the form name in the UI and must be unique per site.data-netlify/netlify attribute and injects <input type="hidden" name="form-name" value="contact" />.<input name="email"> so the notification email's Reply-to is set to the submitter.Two required pieces:
1. Static skeleton file public/__forms.html — a hidden copy of each form with data-netlify="true", a hidden form-name input, and every field the component submits, with names matching exactly (Netlify validates field names against the registered form). Without this file, submissions silently fail.
<!-- public/__forms.html -->
<form name="pizzaOrder" data-netlify="true" hidden>
<input type="hidden" name="form-name" value="pizzaOrder" />
<input name="order" type="text" />
</form>2. 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>⚠️ SSR POST target: In SSR apps, fetch("/") is intercepted by the SSR catch-all function and never reaches form processing. POST to the static skeleton file itself — /__forms.html — not / or an arbitrary path.
⚠️ Astro on-demand routes: Routes with export const prerender = false or output: "server" are never scanned at build time, so their forms are never registered. Put the form on a prerendered page, or rely on the static skeleton file.
Next.js Runtime v5 (Next.js 13.5+): extract form definitions to the static skeleton file and submit via AJAX rather than full-page navigation. See https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview#v5-breaking-changes
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);form-name input, you MUST include a form-name field in the POST body.g-recaptcha-response (if used) must be in the body — automatic with FormData().Add type="file"; optionally enctype="multipart/form-data" on the <form>. For AJAX file uploads, do NOT set a Content-Type header — 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 */ });
});Limits: one file per field (use multiple fields for multiple files) · 8 MB max request size · 30 s upload timeout · after form deletion, uploaded files stay at their direct URL for 24 h. PII uploads need extra security (Very Good Security integration).
Add an action path relative to site root, starting with /. Use extensionless paths — Netlify serves thank-you.html at /thank-you; the .html path returns 404.
<form name="contact" action="/thank-you" method="POST" data-netlify="true"></form>Custom success alert is only possible via AJAX (substitute the redirect with your own logic).
All submissions are filtered by Akismet. Passed → Verified submissions; flagged → Spam submissions. Honeypot/reCAPTCHA failures are rejected and appear in neither list.
Honeypot: add netlify-honeypot="bot-field" to the <form> and include a CSS-hidden field of that name. Any value entered → submission quietly rejected.
<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>Netlify reCAPTCHA 2: add data-netlify-recaptcha="true" to the <form> AND an empty <div data-netlify-recaptcha="true"></div> where it renders. Only ONE Netlify-provided challenge per page — for multiple, use custom reCAPTCHA. For JS-rendered forms, also add the div to the static skeleton file.
Custom reCAPTCHA 2: your own reCAPTCHA snippet + data-netlify-recaptcha="true" on the <form>, plus env vars:
SITE_RECAPTCHA_KEY — site key (scopes: Builds + Runtime)SITE_RECAPTCHA_SECRET — secret (scope: Runtime)Default sender: formresponses@netlify.com. Set subject via a hidden subject input or the Netlify UI (Configuration > Notifications) — not both; the HTML value always overrides the UI.
<input type="hidden" name="subject" value="New lead from %{formName} (%{submissionId})" />Variables: %{formName}, %{siteName}, %{submissionId}. Forms created before May 5, 2023 carry a [Netlify] subject prefix — remove it by adding the data-remove-prefix attribute to the subject input.
Set up notifications (email/webhook/Slack) in the UI: Configuration > Notifications > Form submission notifications > Add notification.
Use only documented surfaces. Do NOT invent api.netlify.com endpoints or read tokens from local CLI config files. Reference: https://open-api.netlify.com/#tag/submission/operation/listFormSubmissions
Link header — code that reads only the first response silently drops the rest.listFormSubmissions returns data from old/removed fields no longer shown in the UI.?state=spam.The UI summary is derived from field type, not name:
<input> that isn't email-like (type="email", or name matching email/mail/from/twitter/sender); falls back to a field named title or subject.<textarea>.Field order in the HTML affects what appears in the summary.
?state=spam) and mark it verified. Do NOT build a custom recovery function or disable spam filtering as a first resort.test@test.com), write full sentences, don't hammer from one IP./.hidden instead of removing them to keep them visible; old data remains available via listFormSubmissions.404, past submissions become unavailable. Export CSV first.<script> → escaped entities).These are org conventions and field-learned guardrails, not docs facts — they are merged into the rendered skill by ctx-gen and are never generated. Extracted from the previous hand-written netlify-forms skill; owned by the skills maintainer.
fetch("/") is intercepted
by the SSR catch-all function and never reaches Netlify's form processing.
POST the AJAX submission to the static skeleton file itself (e.g.
/__forms.html), not to an arbitrary path.https://api.netlify.com/...
with an invented endpoint shape, and do not read tokens out of local CLI
config files (~/Library/Preferences/netlify/config.json).Link
header); code that reads only the first response silently drops the rest.public/__forms.html: a hidden copy of each form with
data-netlify="true", a hidden form-name input, and every field the
component submits — names matching exactly (Netlify validates field names
against the registered form). Without this file, submissions silently fail.export const prerender = false, or
output: "server" routes) are never scanned at build time, so their forms
are never registered. Put the form on a prerendered page or rely on the
static skeleton file.?state=spam) and mark it verified.
Do not build a custom recovery function or disable spam filtering as a
first resort.action paths (/thank-you,
not /thank-you.html) — Netlify serves thank-you.html at /thank-you
and the .html path returns 404.47848e2
Also appears in
last in sync May 18, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.