The short answer
Netlify has a built-in form handler: add a netlify attribute to your form and Netlify's build step detects it and starts capturing submissions into your site dashboard, with no code at all. It's the fastest option if you're staying on Netlify. An external endpoint like RG Forms is the better fit when you want submissions stored in your own Google Sheet rather than a host dashboard, when the site may move hosts later, or when the same form code needs to run identically across several projects — because a plain fetch() has no host-specific build magic behind it.
Two good options, different shapes
| Netlify Forms | RG Forms | |
|---|---|---|
| Setup | Add a netlify attribute; detected at build | Sign in with Google; POST to your endpoint |
| Where submissions live | Your Netlify site dashboard | A Google Sheet in your own Drive |
| Cost | Included, with a monthly submission allowance per plan — see Netlify’s pricing | Free; no infrastructure to bill for |
| If you move hosts | Form handling stops; export first | Nothing changes — the endpoint isn’t tied to a host |
| Spam handling | Built-in filtering, plus honeypot and captcha options | Honeypot, plus optional Cloudflare Turnstile verified server-side |
| Works locally / on any preview | Only on deployed Netlify builds | Anywhere, including localhost |
Neither of these is the “right” answer in general. Pick on data custody and portability, not on features — both cover the basics well.
When Netlify Forms is the better choice
You’re staying on Netlify, you want zero JavaScript, and you want submissions in the same dashboard as your deploys. It also handles a plain non-JS form post with a redirect, which is genuinely useful if you care about the no-JavaScript case.
When an external endpoint fits better
The site might move. Agency work gets migrated. When it does, host-native forms are the piece that quietly breaks, and the submission history has to be exported before the account is closed.
The client should own the data. Handing over a Google Sheet in the client’s own Drive is a cleaner boundary than sharing access to your Netlify account, and it doesn’t depend on your billing relationship continuing.
You want it working in local dev. Netlify’s form detection happens at deploy time, so you can’t exercise the real path on localhost without a deploy. A fetch to an endpoint behaves identically everywhere.
You maintain many sites. One integration pattern that’s identical across every project — regardless of host or framework — is worth a lot when you’re maintaining a dozen of them.
Wiring up an external endpoint on Netlify
Create the RG Forms project
Sign in with Google, define your fields, and copy the endpoint URL.
Store the URL as an environment variable (optional)
In Site configuration → Environment variables, add e.g.
VITE_FORM_ENDPOINTorPUBLIC_FORM_ENDPOINTdepending on your framework’s prefix convention. This isn’t about secrecy — the URL ends up in the client bundle regardless — it’s so a rebuild can repoint the form without a code change.Add the form
Note there’s no
netlifyattribute and nodata-netlify— you’re deliberately opting out of the build-time detection.index.html <form id="contact-form"> <label>Name <input name="name" required /></label> <label>Email <input type="email" name="email" required /></label> <label>Message <textarea name="message" required></textarea></label> <!-- Honeypot: humans never see it, bots fill it in --> <input type="text" name="_hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true" /> <button type="submit">Send</button> <p id="form-status" role="status"></p> </form> <script> const ENDPOINT = "https://script.google.com/macros/s/AKfycb.../exec"; const form = document.getElementById("contact-form"); const status = document.getElementById("form-status"); form.addEventListener("submit", async (event) => { event.preventDefault(); status.textContent = "Sending…"; const res = await fetch(ENDPOINT, { method: "POST", // text/plain avoids the CORS preflight Apps Script can't answer headers: { "Content-Type": "text/plain" }, body: JSON.stringify({ tab: "contact", fields: Object.fromEntries(new FormData(form)), }), }); const data = await res.json(); status.textContent = data.result === "success" ? "Thanks — we'll be in touch." : "Something went wrong."; if (data.result === "success") form.reset(); }); </script>Deploy and submit a real message
Confirm the row lands in your Sheet. If you’d rather test before deploying, hit the endpoint directly.
Test the endpoint from your terminal curl -L -X POST "https://script.google.com/macros/s/AKfycb.../exec" \ -H "Content-Type: text/plain" \ -d '{"tab":"contact","fields":{"name":"Ada","email":"ada@example.com","message":"Hello"}}' # → {"result":"success"} and a new row appears in your Google Sheet
Common questions
Will Netlify try to capture my fetch-based form too?
No. Netlify’s build step looks for forms marked with the netlify attribute. Without it, your form is just markup and Netlify ignores it.
Do I need a Netlify Function for this?
No — that’s the point. The browser POSTs straight to your Apps Script endpoint, so there’s no function to write, deploy, or count against your invocation limits.
Does it work on Netlify deploy previews?
Yes, on every preview and branch deploy, and on localhost too. There’s no build-time detection involved.
What about Netlify’s spam filtering?
That applies to Netlify Forms. For an external endpoint you use the endpoint’s own protection: the hidden _hp honeypot, plus Cloudflare Turnstile if you want a server-side captcha check before rows are written.
More answers in the full FAQ.