Sheetspin logoSheetspin
Capture7 min read

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.

The DIY way
  • 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
The owned way
  • 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
Spin up Contact Form in one click

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);
}
An Apps Script endpoint that appends a submission to your Sheet.

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.

← All guidesBrowse the catalog →