React forms to Google Sheets
A fetch in your submit handler or one script tag that binds your forms automatically - either way, submissions land as rows in Google Sheets or Excel.
React gives you complete control over form state and none over where submissions go - that part still needs an endpoint. SheetLink Forms provides one: a permanent URL at https://sheetlinkforms.com/f/{token} that accepts JSON, urlencoded, or multipart POSTs and files each accepted submission as a row in Google Sheets or an Excel Online table. Delivery is direct to the Google Sheets API or Microsoft Graph via an asynchronous worker with retries at 5 minutes, 30 minutes, and 2 hours - no Zapier in the middle, and no per-submission fees.
Because this is a browser-first product, the parts that are usually annoying in a SPA are handled: the endpoint supports cross-origin fetch, the sl.js embed binds forms even when they mount after initial load (it watches the DOM with a MutationObserver), and ad attribution - utm_* plus gclid, wbraid, gbraid, fbclid, and msclkid - is captured from the URL and persisted in localStorage across client-side navigations.
SheetLink Forms is free during beta with an invite - join the waitlist - and you can poke a working form on the live demo.
The best path for React
Two paths work well in React. The least-code path is the sl.js embed: one script tag with your token, then mark any form with data-sheetlink. The script AJAX-submits it, shows inline success text, auto-injects the honeypot, adds a timing signal for spam screening, and captures ad attribution - and because it uses a MutationObserver, forms rendered by React after page load are bound too.
The full-control path is a fetch in your own submit handler, as in the snippet below. One detail matters: send an Accept: application/json header (or post a JSON body with Content-Type: application/json) so the endpoint answers {"ok":true,"id":"..."} instead of the 303 redirect intended for classic HTML forms. CORS is not a fight here - the endpoint is built for cross-origin browser posts, with an optional per-form origin allowlist when you want to pin submissions to your domains.
1. Connect a sheet and get your endpoint
Connect Google Sheets with one-click OAuth - the drive.file scope limits access to sheets you pick in the Google picker or create in the product, never the rest of your Drive - or connect Excel Online and choose a table, whose columns define the row shape. Your form gets a permanent endpoint at https://sheetlinkforms.com/f/{token}.
2. Easiest path: drop in sl.js
Add <script src="https://sheetlinkforms.com/sl.js" data-token="slf_yourtoken"></script> to your HTML shell (for example index.html in a Vite app) and put data-sheetlink on your form element. The script binds it - including forms that mount later, via MutationObserver - AJAX-submits, swaps in inline success text, injects the honeypot, and records the timing signal. It emits sheetlink:success and sheetlink:error events if you want to drive your own UI from them.
3. Full-control path: write the fetch yourself
Use the snippet above. Building the body with new FormData(e.currentTarget) keeps your inputs uncontrolled and simple; a JSON body with Content-Type: application/json works just as well. Either way, keep the request under the 256KB body cap and read result.ok for your UI state.
4. Keep the honeypot input in your JSX
When you handle the fetch yourself, render the _slhp input exactly as in the snippet - tabIndex={-1}, autoComplete="off", positioned off-screen with a style object. It is the one signal that marks spam outright; everything else suspicious (rate limits, content heuristics, timing) goes to a quarantine for one-click review and is never silently dropped. The docs cover the full screening stack, including optional Cloudflare Turnstile.
5. Name inputs after your columns
Field names that match your sheet's column headers map automatically, and matching is case and punctuation insensitive - Email, email, and E-mail all land in the same column. Explicit per-field mapping is available when names and headers differ. On an empty sheet the header row is seeded on first delivery; rows then append under it.
6. Test and confirm in the delivery log
Submit a test from your dev server and check the delivery log in the dashboard - every attempt is recorded, and the asynchronous worker retries at 5 minutes, 30 minutes, and 2 hours if the spreadsheet API hiccups. The whole pipeline is diagrammed on how it works.
FAQ
Should I use sl.js or my own fetch?
Use sl.js when you want the shortest path: binding, inline success, honeypot injection, timing signal, and ad attribution all come free, and its MutationObserver means React-rendered forms are picked up whenever they mount. Write your own fetch when you want submission state inside your component - loading spinners, optimistic UI, custom validation. Both hit the same endpoint and land the same rows.
Will a browser fetch hit CORS errors?
No - the endpoint is built for cross-origin browser posts. If you enable the per-form origin allowlist, posts from origins not on the list are refused with a 403, which is a feature: it stops other sites from spraying your form. Leave the allowlist empty and any origin may post.
How do I show success and error states in my UI?
With your own fetch, branch on the JSON response: {"ok":true,"id":"..."} on acceptance, or a non-2xx status (429 rate limited, 413 too large, 403 origin refused) on rejection. With sl.js, listen for the sheetlink:success and sheetlink:error events that bubble from the form, with details in event.detail.
Does ad attribution survive client-side routing?
Yes. sl.js captures utm_source/medium/campaign/term/content and the gclid, wbraid, gbraid, fbclid, and msclkid click IDs when the visitor first lands, and persists them in localStorage - so the values are still attached when the form is submitted several routes later.
What does it cost?
Free during beta, invite required - the waitlist is on the homepage. Planned post-beta pricing (planned, not live): a free tier around 50 submissions/month and a paid plan around $15-19/mo, unmetered. No per-submission fees, ever.
Also on: Next.js · Plain HTML · Framer