How to Build a Contact Form That Saves to Google Sheets
You want a contact form whose submissions land somewhere you control — not a third-party inbox you rent. A Google Sheet is a perfect store: rows you own, sortable and exportable.
You can wire this up by hand with Apps Script, or provision a form that already does it. Here is both, including the one CORS detail that trips most people up.
- Add a header row: submitted_at, name, email, message
- Write a doPost(e) that appends the posted fields
- Deploy as a Web App (execute as you, anyone can access)
- POST JSON from your site — with Content-Type: text/plain
- Then build the page, spam filtering, and notify emails yourself
- Pick the app from the catalog
- Fill in a short form — no code
- One click provisions it into your Google account
- It runs as you, in your Drive, yours to keep
The DIY way
The DIY route is an Apps Script web app with a doPost that appends each submission as a row.
function doPost(e) {
try {
var body = JSON.parse(e.postData.contents);
var fields = body.fields || {};
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('submissions');
var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
var row = headers.map(function (h) {
if (h === 'submitted_at') return new Date().toISOString();
return fields[h] != null ? String(fields[h]) : '';
});
sheet.appendRow(row);
return json({ result: 'success' });
} catch (err) {
return json({ result: 'error', error: err.message });
}
}
function json(d) {
return ContentService.createTextOutput(JSON.stringify(d))
.setMimeType(ContentService.MimeType.JSON);
}The DIY way, step by step
Create a tab called "submissions" with a header row whose columns match your form fields, starting with submitted_at. Paste the doPost above into Apps Script, then Deploy → New deployment → Web app, set "Execute as: Me" and "Who has access: Anyone", and copy the /exec URL.
From your own site, POST to that URL with a JSON body of the shape { fields: { name, email, message } }.
The CORS gotcha nobody mentions
Send the request with Content-Type: text/plain — not application/json. Apps Script web apps cannot answer the CORS preflight (OPTIONS) request that application/json triggers, so the browser blocks the call. The body is still JSON; only the header changes. This one line saves hours of debugging.
await fetch(WEB_APP_URL, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' }, // avoids the CORS preflight
body: JSON.stringify({ fields: { name, email, message } }),
});The parts the tutorial skips
A working endpoint is the easy 20%. A real contact form also needs a branded page with a success state, spam protection (a honeypot plus a captcha), and a notification email so you actually find out someone wrote in. Each is another block of code to write, test, and maintain.
The one-click alternative: Contact Form
The Contact Form app provisions all of that into your own Google account: a hosted, branded form page at its own URL (share it or embed it) that is also the JSON endpoint your site can POST to. Submissions append to your Sheet, with a honeypot and optional Cloudflare Turnstile captcha verified server-side, plus notification emails (CC/BCC/subject/reply-to).
You define the fields in a short form; the columns, the page, and the endpoint are generated for you. It is yours to keep — no per-submission fees, no vendor holding your leads.
Provision Contact Form into your own Google account
Spin up a hosted, branded contact form at its own URL (share or embed it), with a live success state. It is also a JSON endpoint your own site can POST to. Submissions append to your Sheet and can email you, with honeypot + Cloudflare Turnstile spam protection.
Frequently asked questions
Can a Google Sheet receive form submissions?
Yes — via an Apps Script web app with a doPost that appends a row per submission. Deployed as a web app, it gives you a public HTTPS endpoint backed by your Sheet.
Why must I use Content-Type: text/plain?
application/json triggers a CORS preflight (OPTIONS) that Apps Script web apps cannot answer, so the browser blocks the request. text/plain skips the preflight; the body is still JSON.
Is it free?
Yes. It runs inside your own Google account on Apps Script’s free quotas — no third-party form service or subscription.