
If you have a contact form, an opt-in widget, or a custom HTML form on your site, you can route every submission straight into a Google Sheet without a database, a Zapier subscription, or a paid backend. Three free methods get you there. Two need code. One does not.
This guide gives you the working Apps Script (paste-ready), the no-code path, and a side-by-side comparison so you know which one to pick before you start.
Quick answer
You can push HTML form submissions into a Google Sheet for free with three methods. Pick one in 30 seconds:
- Google Forms (2 minutes, no code): use it when the form does not need to live on your own domain.
- Google Apps Script (20-40 minutes, paste-ready code below): use it when you already have an HTML form on your site and are fine maintaining a script.
- No-code builder like Formester (5 minutes, no code): use it when you want spam control, conditional logic, and Sheets sync in one place.
- Apps Script caps at roughly one Sheets write per second per user, which is plenty for a contact form.
- Never store payment data or passwords in Sheets. It is a great inbox, not a vault.
Which method should you pick?
Three free routes from form submission to a Google Sheet row. Match the method to where you already are.
Google Forms
Best for: A form you do not need to embed on your own site.
- 2-minute setup, no code at all.
- Free with any Google account.
- Responses auto-link to a Sheet.
- Lives on a Google URL, not your domain.
- Limited design control.
Google Apps Script
Best for: A custom HTML form you already have on your site.
- Free forever, no usage cap on Sheets writes (within Google quotas).
- Paste-ready code, no edits required.
- Stays on your domain, no third-party branding.
- 20-40 minute setup.
- You maintain the script and the deployment.
Formester
Best for: Embedding on any site with spam control, conditional logic, and Sheets sync.
- 5-minute setup, no code at all.
- Free plan covers 100 responses/month.
- Native Google Sheets integration, OAuth, retries handled.
- Spam filtering, conditional logic, analytics built in.
- Branded footer on the free plan.
Decision rule
- If the form already exists in HTML and you are fine maintaining a script, use Apps Script.
- If you do not need to embed the form on your own domain, use Google Forms.
- If you want zero maintenance, spam control, conditional logic, and Google Sheets sync in one place, use a no-code form builder like Formester.
Method 2: Google Apps Script, full working setup
Method 1 is one click in Google Forms. Method 3 is a no-code toggle in Formester. The only method that needs a real walkthrough is Method 2. Here it is, in full.
Paste-ready code that handles any field name in your HTML form, sanitizes against CSV formula injection, and works with both application/json and standard form-encoded submissions.
-
Create the Sheet
Open Google Sheets and create a new spreadsheet. Name the tab
Submissions. In row 1, add column headers that match thenameattributes on your form inputs. Addtimestampas the first column. The script reads row 1 as the source of truth for column mapping, so a typo in a header is a typo in the data.Example row 1:
timestamp | name | email | message -
Open Apps Script and paste the code
Inside the Sheet, go to Extensions, then Apps Script. Delete the boilerplate and paste this:
Code.gsconst SHEET_NAME = 'Submissions'; const SCRIPT_PROP = PropertiesService.getScriptProperties();
function initialSetup() { const activeSpreadsheet = SpreadsheetApp.getActiveSpreadsheet(); SCRIPT_PROP.setProperty('key', activeSpreadsheet.getId()); }
function doPost(e) { const lock = LockService.getScriptLock(); lock.tryLock(10000);
try { const doc = SpreadsheetApp.openById(SCRIPT_PROP.getProperty('key')); const sheet = doc.getSheetByName(SHEET_NAME);
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; const nextRow = sheet.getLastRow() + 1; // Honeypot: if a hidden field named "website" is filled, drop the submission. if (e.parameter.website) { return ContentService.createTextOutput(JSON.stringify({ result: 'ignored' })) .setMimeType(ContentService.MimeType.JSON); } const sanitize = (value) => { if (typeof value !== 'string') return value; // Block CSV / formula injection return /^[=+\-@]/.test(value) ? "'" + value : value; }; const newRow = headers.map((header) => { if (header === 'timestamp') return new Date(); return sanitize(e.parameter[header] || ''); }); sheet.getRange(nextRow, 1, 1, newRow.length).setValues([newRow]); return ContentService .createTextOutput(JSON.stringify({ result: 'success', row: nextRow })) .setMimeType(ContentService.MimeType.JSON);} catch (err) { return ContentService .createTextOutput(JSON.stringify({ result: 'error', error: err.toString() })) .setMimeType(ContentService.MimeType.JSON); } finally { lock.releaseLock(); } }
Save the script. Then in the function dropdown at the top of the editor, pick
initialSetupand click Run once. Approve the OAuth prompt when Google asks. You will only do this once. -
Deploy as a web app
Click Deploy, then New deployment. Pick Web app. Set Execute as: Me. Set Who has access: Anyone. Click Deploy. Copy the long URL that ends in
/exec. That URL is your form endpoint.Heads-up: every time you change the script, redeploy and grab the new URL. Old URLs keep working at the old script version, which is the most common cause of "I updated my code and nothing changed" complaints. -
Wire up your HTML form
Drop this into your page. Replace
PASTE_YOUR_DEPLOYMENT_URL_HEREwith the URL from step 3.index.html<form id="contact-form"> <input type="text" name="name" required placeholder="Name" /> <input type="email" name="email" required placeholder="Email" /> <textarea name="message" required placeholder="Message"></textarea> <!-- Honeypot: hidden from humans, filled by bots --> <input type="text" name="website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" /> <button type="submit">Send</button> </form>
<script> const form = document.getElementById('contact-form'); const endpoint = 'PASTE_YOUR_DEPLOYMENT_URL_HERE';
form.addEventListener('submit', async (e) => { e.preventDefault(); const data = new FormData(form); try { await fetch(endpoint, { method: 'POST', body: data }); form.reset(); alert('Thanks. We will be in touch.'); } catch (err) { alert('Something went wrong. Try again in a minute.'); } }); </script>
Submit a test entry. The row should appear in your Sheet within a second.
Rate limit to know about: Google caps the Sheets-backed Apps Script at roughly 100 requests per 100 seconds per user, or one write per second. For a contact form that is plenty. If you expect bursts (a launch, a paid ad campaign), queue submissions or use a builder that handles retries.
Send HTML form data to Google Sheets, without writing a single line of code
Drop in a Formester form, connect Google Sheets, done. Two-way sync, free plan, no script to maintain.
Start free on FormesterFree forever plan•No credit card•Setup in 2 minutes
More on connecting forms to Google Sheets
More on connecting forms to Google Sheets, building APIs around them, and picking the right builder.
Best form builders compatible with Google Sheets
Side-by-side on price, sync mode, and field mapping.
Read How-toHow to create a Google Sheets API and use it as a live backend
Treat a Sheet as a read/write database for a real app.
Read How-toHow to link Google Sheets to Google Forms
The 2-minute setup for the Google Forms route.
Read How-toPre-fill forms using Google Sheets and Formester
Reverse the flow: pull Sheet data into a form before it loads.
Read How-toHow to link Squarespace forms to Google Sheets
Same idea, scoped to a Squarespace site.
Read FormesterGoogle Sheets integration
Native sync with OAuth, header mapping, and retries handled.
Open


