How to send HTML form submissions to Google Sheets
Someone on your team lives in a spreadsheet. Leads, event sign-ups, support requests: if it isn't in the sheet, it didn't happen. So when you add a form to the website, the request is predictable. "Can the submissions go straight into the sheet?"
They can, and you don't need a server for it. Google Sheets can receive data from an HTML form through a small Apps Script web app. This guide builds that version properly, including the parts most tutorials skip, then covers what it still leaves you maintaining and when a form backend's built-in integration is the better trade.
Two ways to get form data into a sheet
- An Apps Script web app. You write a short script attached to the spreadsheet and deploy it with a public URL. Your form posts to that URL, and the script appends a row. It's free, and you own every line of it.
- A form backend with a Google Sheets integration. The form posts to the backend, which stores the submission, filters spam, and appends the row for you. Nothing to deploy, but it's a service you depend on.
Both end with a row in your sheet. The difference is everything that happens before the row is written.
Option 1: Google Apps Script
Set up the spreadsheet
Create a spreadsheet, rename the first tab to Submissions, and type a header row into columns A to D: Timestamp, Name, Email, and Message.
Then copy the spreadsheet's ID from its URL. In https://docs.google.com/spreadsheets/d/1AbCdEf.../edit, it's the long string between /d/ and /edit.
Write the script
In the spreadsheet, open Extensions → Apps Script, replace the contents of Code.gs with the following, and paste your spreadsheet ID into the first line:
// Code.gs
const SPREADSHEET_ID = "YOUR_SPREADSHEET_ID";
const SHEET_NAME = "Submissions";
const FIELDS = ["name", "email", "message"]; // columns after the timestamp
function doPost(e) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(10000)) {
return json({ ok: false, error: "Busy. Please try again." });
}
try {
const data = JSON.parse(e.postData.contents);
// Honeypot: people never see this field, so a value means a bot.
if (data.company) return json({ ok: true });
const sheet =
SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName(SHEET_NAME);
sheet.appendRow([
new Date().toISOString(),
...FIELDS.map((field) => asText(data[field])),
]);
return json({ ok: true });
} catch (err) {
console.error(err);
return json({ ok: false, error: "Your message couldn't be saved." });
} finally {
lock.releaseLock();
}
}
// A cell that starts with =, +, -, or @ can be read as a formula.
// A leading apostrophe forces the value to stay plain text.
function asText(value) {
const text = (value == null ? "" : String(value)).slice(0, 5000);
return /^[=+\-@\t\r]/.test(text) ? "'" + text : text;
}
function json(body) {
return ContentService.createTextOutput(JSON.stringify(body)).setMimeType(
ContentService.MimeType.JSON
);
}Most of this is plumbing, but four decisions are doing real work.
doPost reads the raw body. Apps Script runs doPost(e) for every POST request, and e.postData.contents holds the request body as text. Sending JSON and parsing it yourself keeps the script independent of how the browser encodes form fields.
The lock. Two visitors submitting at the same moment run two copies of the script at once. LockService makes them take turns, so rows don't collide. If the lock isn't free within ten seconds, the script tells the visitor to retry instead of failing silently.
FIELDS is an allowlist. Only the fields you name become columns, in the order you name them. A bot that posts fifty extra fields gets none of them into your sheet.
asText defends against formula injection. Spreadsheets treat a value that starts with =, +, -, or @ as a formula. If a visitor types =HYPERLINK("https://evil.example", "Click me") into your message field, a naive script writes a live link into a sheet your team trusts. OWASP documents this class of attack as CSV injection and lists a leading single quote as one mitigation, which is what asText adds. The slice(0, 5000) caps how much text one field can dump into a cell.
Deploy it as a web app
- Click Deploy → New deployment, and choose Web app as the type.
- Set Execute as to Me, so the script can write to your spreadsheet.
- Set Who has access to Anyone, so your website's visitors can reach it without a Google login.
- Click Deploy, approve the authorization prompt, and copy the Web app URL. It ends in
/exec.
When you change the script later, use Deploy → Manage deployments, edit the existing deployment, and select a new version. That keeps the same URL. Creating a new deployment gives you a new URL, and your form keeps posting to the old one.
Connect the form
<form id="contact-form">
<label for="name">Name</label>
<input id="name" name="name" type="text" required autocomplete="name" />
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email" />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<!-- Honeypot: hidden from people, visible to bots -->
<input
name="company"
type="text"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
style="position: absolute; left: -9999px"
/>
<button type="submit">Send</button>
<p id="form-status" role="status"></p>
</form>
<script>
const SCRIPT_URL =
"https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec";
const form = document.getElementById("contact-form");
const statusEl = document.getElementById("form-status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
statusEl.textContent = "Sending…";
try {
const res = await fetch(SCRIPT_URL, {
method: "POST",
// No Content-Type header: a string body is sent as text/plain,
// which keeps this a simple request with no CORS preflight.
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const result = await res.json();
if (!result.ok) throw new Error(result.error);
form.reset();
statusEl.textContent = "Thanks! Your message was saved.";
} catch {
statusEl.textContent = "We couldn't save your message. Please try again.";
}
});
</script>Replace the URL with the one you copied when you deployed.
Two details matter here. First, the script checks result.ok instead of res.ok. Apps Script's content service has no way to set an HTTP status code, so a failure still arrives as a 200 with ok: false in the body.
Second, the request deliberately doesn't set a Content-Type header. A header like Content-Type: application/json makes the browser send a CORS preflight OPTIONS request first, and Apps Script web apps only handle GET and POST, through doGet and doPost. Sending the JSON as plain text avoids the preflight entirely.
Google also documents that it redirects the script's response to a one-time URL on script.googleusercontent.com. fetch() follows that redirect automatically. Google doesn't document the CORS behavior of that redirect, so test the form from your real domain, not only from localhost, before you rely on reading the response.
Test it
You can test the deployment from a terminal before touching the website:
curl -L "https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec" \
-d '{"name":"Test","email":"test@example.com","message":"Hello"}'The -L flag follows Google's redirect. You should see {"ok":true} and a new row in the sheet. Then try a message that starts with =1+1 and confirm the cell shows the text, not 2.
What the Apps Script version still leaves to you
The script above is solid for what it is, but it's worth being clear about where it stops:
- The web app URL is public. Anyone who finds it can post to it, and there's no rate limiting, so a bot that ignores your honeypot can fill the sheet.
- Nobody is notified. Rows appear quietly, and someone has to remember to check.
- Failures are invisible unless you look. A rejected submission only shows up in the script's execution log.
- Every new field is a code change: add it to FIELDS, add a column, and publish a new version.
- The spreadsheet is your only copy. If someone deletes rows or sorts one column on its own, that data is gone or scrambled.
None of these are bugs in the script. They're the parts of form handling that a spreadsheet was never meant to do. The hidden complexity of handling form submissions walks through that list in more detail, and How to prevent spam in contact forms covers what to add when the honeypot stops being enough.
Option 2: Formtorch's Google Sheets integration
If you'd rather not own a script, Formtorch can append each submission to a sheet for you. The form posts to your Formtorch endpoint like any other form, and the integration writes the row.
Setup happens in the dashboard:
Connect your Google account
Under Settings → Integrations, click Connect Google. Formtorch asks only for access to the spreadsheets you pick, not your whole Drive.
Add a destination to the form
Open the form, go to its Integrations tab, and add a Google Sheets destination. Pick the spreadsheet and the tab to write to.
Point your form at the endpoint
Set the form's action to https://formtorch.com/f/YOUR_FORM_ID, or submit
to it with fetch(). New rows start appearing with the next submission.
Each row starts with six metadata columns (submittedAt, submissionId, formId, formName, isTest, isSuspicious), followed by your form's fields in alphabetical order by name. Formtorch doesn't write a header row, so add one yourself in that order. Because columns follow the fields each submission contains, keep the same fields in every submission. A checkbox, for example, sends nothing at all when it's unchecked, which shifts later columns in that row; a select or radio group with an explicit "No" value avoids that.
The parts the script left to you are handled before the row is written:
- Spam and test submissions aren't appended. Every submission is scored for spam first, and flagged submissions stay in the dashboard instead of your sheet.
- Values are written as plain text. Formtorch appends with the Sheets API's
RAWinput mode, so a value starting with=is stored as text and never evaluated. - Failed deliveries are retried. If Google is briefly unavailable, Formtorch retries automatically, up to six attempts in total, and each destination's delivery status is visible on the submission.
- The sheet isn't your only copy. Submissions are stored in Formtorch too, so a deleted row doesn't mean a lost submission.
Google Sheets is available on the Starter plan (one destination per form) and the Pro plan (unlimited). The Google Sheets integration docs cover reconnecting, downgrades, and troubleshooting. If you'd rather have every submission emailed to you as well, A developer's guide to email notifications for form submissions explains how that side works.
Wrapping up
Getting an HTML form into Google Sheets takes one small script: read the body in doPost, lock while you write, allowlist your columns, and neutralize anything a spreadsheet might read as a formula. Deploy it once, update it in place, and check result.ok on the client.
What the script can't give you is everything around the row: spam filtering, notifications, retries, and a copy of the data that doesn't live in a shared spreadsheet. If you need those, that's the point where a form backend earns its place.
Want submissions in your sheet without the script?
Connect Google Sheets to a Formtorch form and get spam-filtered rows, retries, and a stored copy of every submission.
Related posts

How to build a Node.js contact form that sends email
