OxyPages

How To Connect Your Forms To Google Sheets (Apps Script)

How To Connect Your Forms To Google Sheets (Apps Script)

Send your forms to Google Sheets with one Apps Script Web App. Complete copy-paste code, the deploy settings people get wrong, and how to test it works.

The OxyPages Team · · 13 min read

Your form works, the entries are landing, and now you want them in a spreadsheet. Connecting your forms to Google Sheets is the most common follow-up request after a contact form goes live, and not because the dashboard is bad. It is because a spreadsheet is where people already sort, filter, share and chart things.

You do not need a paid integration or an automation subscription. Google hands you a free, permanent endpoint of your own: an Apps Script Web App that lives in your Google account and appends a row every time someone submits.

This guide gives you both halves in full: the Apps Script that receives the submission, the script that sits on your page, the deploy settings people get wrong, and what happens when a row never arrives.

If you have not built the form itself yet, start with adding a form to your page and come back once entries are arriving.

TL;DR: Make a Google Sheet, open Extensions then Apps Script, and paste the doPost script below. Deploy it with Deploy, New deployment, Web app, "Execute as: Me" and "Who has access: Anyone". Copy the /exec URL into the page script below, which listens for the oxy:submitted event and posts the same fields across. Sending your forms to Google Sheets takes about ten minutes. OxyPages stores every entry on its Forms page either way, so the Sheet is a mirror, not the record.

Why Send Your Forms To Google Sheets At All?

Because a dashboard is built for reading one entry at a time, and a spreadsheet is built for looking at all of them at once.

Past about twenty leads the questions change. Which week was busiest. How many picked the "pricing" option. Who has not been replied to yet. Those are one-formula questions in a Sheet and a lot of scrolling anywhere else.

Three other reasons come up again and again:

  • Sharing without accounts. Anyone you share the Sheet with can read the entries, with no login for your hosting.
  • Room to grow. Your Forms page keeps the newest 5,000 entries per website. One spreadsheet holds up to 10 million cells, so a three-column form has room for over a million rows.
  • It feeds other tools. Looker Studio, a mail merge, an accounting import and most CRMs all read a Sheet happily.

What You Need Before You Start

Five things, and you likely have four already.

  1. A published website with a working form. Forms only submit on the live address, never in the editor preview.
  2. Captcha keys saved in Form Settings. Submissions are rejected until a provider is configured, on every plan.
  3. A Google account and a Sheet you own.
  4. Access to edit that page's HTML.
  5. Ten minutes.

Getting your forms to Google Sheets needs no server and no third-party service. The Apps Script Web App is the whole backend, hosted free by Google.

Step 1: Make The Sheet And Open Apps Script

Open a new spreadsheet at sheets.new and give it a name you will recognise in six months, like "Website Enquiries".

Choose Extensions from the menu bar, then Apps Script. A new tab opens with a file called Code.gs holding an empty myFunction. Select all of it and delete it.

Rename the project too: click "Untitled project" at the top left. Keep this tab open. The script is bound to this spreadsheet, which lets it write rows without keys or IDs.

Step 2: Paste The Apps Script That Writes The Row

Step 2: Paste The Apps Script That Writes The Row - OxyPages

This is the whole receiving end. It takes the posted fields, adds a column for any field name it has not seen before, and appends one row in header order.

/**
 * Form submissions -> this spreadsheet.
 * Paste into Extensions > Apps Script on the Sheet you want filled.
 */

var SHEET_NAME = 'Submissions';

function doPost(e) {
  var lock = LockService.getScriptLock();
  try {
    lock.waitLock(20000);

    var params = (e && e.parameter) ? e.parameter : {};
    if (!Object.keys(params).length) return json_({ ok: false, error: 'no fields posted' });

    var sheet = getSheet_();
    var headers = readHeaders_(sheet);

    // Any field name we have not seen before becomes a new column.
    var known = {};
    for (var i = 0; i < headers.length; i++) known[headers[i]] = true;
    var grew = false;
    for (var key in params) {
      if (!known[key]) {
        headers.push(key);
        known[key] = true;
        grew = true;
      }
    }
    if (grew) sheet.getRange(1, 1, 1, headers.length).setValues([headers]);

    // Build the row in header order so the columns never drift.
    var row = [];
    for (var j = 0; j < headers.length; j++) {
      row.push(headers[j] === 'Timestamp' ? new Date() : cell_(params[headers[j]]));
    }
    sheet.appendRow(row);

    return json_({ ok: true, columns: headers.length });
  } catch (err) {
    return json_({ ok: false, error: String(err) });
  } finally {
    lock.releaseLock();
  }
}

function doGet() {
  return json_({ ok: true, message: 'Endpoint is live. POST your form fields here.' });
}

function getSheet_() {
  var ss = SpreadsheetApp.getActive();
  return ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME);
}

function readHeaders_(sheet) {
  if (sheet.getLastRow() === 0) {
    sheet.getRange(1, 1, 1, 1).setValues([['Timestamp']]);
    sheet.setFrozenRows(1);
    return ['Timestamp'];
  }
  var values = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  return values.map(function (v) { return String(v); });
}

// A value starting with "=" would be saved as a formula. Keep it as text.
function cell_(value) {
  var s = (value === undefined || value === null) ? '' : String(value);
  return s.charAt(0) === '=' ? "'" + s : s;
}

function json_(obj) {
  return ContentService
    .createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}

Three details separate a Sheet you trust from one you clean up every month.

LockService stops two submissions that arrive in the same second from overwriting each other. Without it appendRow can race and you silently lose a lead.

The header row grows by itself. Add a "phone" field to your form later and the next submission creates a "phone" column, older rows left blank. You never edit the script again.

cell_ catches a value starting with an equals sign, so "=1+1" typed by a visitor stays text instead of becoming a formula. doGet exists purely so you can test the deployment in a browser tab.

Press save. Do not run anything yet.

Step 3: Deploy It As A Web App

The deploy settings are where nearly every failed attempt happens, so do these in order.

  1. Click Deploy, then New deployment.
  2. Click the gear next to "Select type" and choose Web app.
  3. Description: "v1" is fine.
  4. Execute as: Me (your address).
  5. Who has access: Anyone.
  6. Click Deploy.
  7. Click Authorize access, pick your account, then Advanced, then "Go to (project name) (unsafe)", then Allow.
  8. Copy the Web app URL. It ends in /exec.

Two of those bite people.

"Who has access" must be Anyone, not "Anyone with Google account". Visitors are not signed in to Google while filling in a contact form, so that option rejects them and no row appears. Google's Apps Script web apps documentation spells out the difference.

The "unsafe" warning in step 7 only means the script has not been through Google's verification review. It is your script, writing to your own spreadsheet.

Open the /exec URL in a browser tab now. You should see the JSON from doGet. A sign-in page means the access setting is wrong.

Remember this for later: editing the code does not change what the URL serves. After any edit, go to Deploy, Manage deployments, click the pencil, set Version to New version, and Deploy again.

Step 4: Mirror Each Submission From Your Page

This is the half that actually sends your forms to Google Sheets. Your form already posts itself and stores the entry; you are not replacing that, just adding a second, quieter request.

The hook is an event called oxy:submitted. It fires on the form after a submission is accepted and before the fields are cleared. That timing is the whole trick: inside the listener the values are still there, so new FormData can read them.

Paste this on the page, before the closing body tag.

<form data-oxy-form="contact">
  <label>Name <input name="name" required></label>
  <label>Email <input name="email" type="email" required></label>
  <label>Message <textarea name="message" required></textarea></label>
  <button type="submit">Send</button>
</form>

<script>
(function () {
  // 1. The /exec URL from your Apps Script deployment.
  var SHEET_URL = "https://script.google.com/macros/s/PASTE_YOUR_DEPLOYMENT_ID/exec";

  // 2. Your form name, and the fields to mirror, in column order.
  var FORM_NAME = "contact";
  var FIELDS = ["name", "email", "message"];

  document.addEventListener("oxy:submitted", function (event) {
    var form = event.target;
    if (!form || form.getAttribute("data-oxy-form") !== FORM_NAME) return;

    var entered = new FormData(form);
    var body = new URLSearchParams();
    for (var i = 0; i < FIELDS.length; i++) {
      body.append(FIELDS[i], entered.get(FIELDS[i]) || "");
    }
    body.append("form", FORM_NAME);
    body.append("page", location.pathname);

    // no-cors: the row is written, but the browser will not show us the reply.
    // keepalive: the request survives the page navigating away afterwards.
    fetch(SHEET_URL, {
      method: "POST",
      mode: "no-cors",
      keepalive: true,
      body: body
    }).catch(function () {
      // The entry is already saved. There is nothing to recover here.
    });
  });
})();
</script>

Three choices in there are deliberate.

FIELDS is an allow-list, not a dump of the whole form. A live form also carries a captcha token and a hidden anti-spam field you did not write, and neither belongs in your spreadsheet. Naming your own fields keeps the columns clean and predictable.

keepalive matters if your form uses data-oxy-redirect to send people to a thank-you page. Without it the browser can kill the request mid-flight as it navigates away. The Fetch API guide on MDN explains the flag.

mode: "no-cors" is what lets a plain page post to a Google endpoint without a preflight request. The cost is real: your page cannot read the reply, so it cannot tell whether the row landed. The next section says why that is fine.

Prefer a custom message or a hidden thank-you block over a redirect? The success message and redirect options are single attributes on the form tag.

A Worked Example: One Form, Three Fields

A Worked Example: One Form, Three Fields - OxyPages

Say that form is on /contact.html and someone called Priya sends an enquiry.

She fills in her name, her email and a two-line message and presses Send. The entry is stored, your notification email goes out, and she sees "Message sent." under the button, as before.

Immediately, before the fields clear, the listener has fired, read the three values, added the form name and page path, and posted them to your /exec URL. Your Sheet, which had one "Timestamp" header a minute ago, now looks like this:

Timestampnameemailmessageformpage
08/09/2026 14:21:03Priyapriya@example.comDo you take on retainer work?contact/contact.html
08/09/2026 16:02:40Marcusm.lee@example.comQuote for 8 pages pleasecontact/contact.html

Six columns, created for you, in the order you listed them. That is your forms to Google Sheets link working end to end. Add a "phone" input next month and a seventh column appears on its own.

Which Copy Is The Real Record?

The stored entry is the record. The Sheet is a convenience mirror, and it is worth being blunt about why.

OxyPages writes its copy server side, at the endpoint that received the submission, before your visitor sees a confirmation. The Sheet row is written by JavaScript in that visitor's browser, afterwards, to a service that may or may not answer. Those are not the same guarantee, and pretending otherwise is how people trust the wrong copy.

Your Forms pageYour Google Sheet
Written byThe submission endpoint, server sideA script in the visitor's browser
Written whenBefore the visitor sees a confirmationA moment later, fire and forget
Survives a broken deploymentYesNo, the row is simply missing
How much it holdsNewest 5,000 entries per websiteUp to 10 million cells per spreadsheet
Spam labellingHoneypot entries are marked SpamArrives unlabelled, mixed in
Email per entryYes, within your plan's monthly allowanceNo
Sort, filter, pivot, chartNoYes
Who can read itPeople with access to the websiteAnyone you share the Sheet with

So when the Apps Script endpoint is down, the deployment access was left on the wrong setting, or a browser extension blocked the request, you lose the row and nothing else. Open the Forms page and the entry is there in full. Sending your forms to Google Sheets buys you a second copy, never a second source of truth.

The email side has its own ceiling. Notifications are capped monthly by plan, and the pricing page carries the current figures. Past that line the emails pause while every submission is still stored. The notification allowance article has the full table.

When The Sheet Stays Empty

When your forms to Google Sheets mirror goes quiet, work down this list in order. The first three account for almost everything.

What you seeUsually meansFix
No rows at all, form works fineDeployment access is "Anyone with Google account"Manage deployments, set access to Anyone
An entry on the Forms page, no row in the SheetThe submit never ran through the built-in form helper: the form carries data-oxy-native, one of your own scripts called preventDefault, or the visitor has JavaScript switched offDrop data-oxy-native and let the helper submit; a visitor with no JavaScript will never mirror
Nothing happens, even the form failsCaptcha keys are not saved in Form SettingsAdd them, then test on the published address
Rows stopped after you edited the scriptThe /exec URL still serves the old versionManage deployments, pencil, Version: New version
A sign-in page at the /exec URLSame access problem as the first rowRedeploy with access set to Anyone
Rows arrive with empty columnsA field has no name attribute, or is missing from FIELDSAdd the name, add it to the array
Only some fields arriveA name in FIELDS does not match the inputMatch them character for character

Two more. Nothing happens in the editor preview, because forms submit only on the published site. And a file input never reaches the Sheet: file uploads are not supported, and only the filename is recorded.

If the form itself is broken rather than the mirror, the form troubleshooting guide is the faster place to start.

What To Do Next

Publish the page, send a real test entry from your phone, and check both places. The Forms page should show the entry, the Sheet should show the row. If only one does, you know which half to look at.

After that it is just a spreadsheet: add a "Replied" column, chart enquiries per week, or point Looker Studio at it. Your forms to Google Sheets setup is now one page edit away from any other form you own.

Still choosing where to host the page? The OxyPages feature list covers what forms do out of the box, including the captcha and anti-spam handling you would otherwise wire up.

Let AI Do It For You

Paste this into the AI editor, or any coding agent, and it wires up the page half of your forms to Google Sheets setup. You still create and deploy the Apps Script yourself, since that lives in your Google account.

Add a Google Sheets mirror to the contact form on this page.

1. Leave the existing <form data-oxy-form="contact"> exactly as it is. Do not
   change its attributes and do not add an action or a method to it.
2. Add a <script> block before </body> that listens on document for the
   "oxy:submitted" event, ignores the event unless event.target has
   data-oxy-form="contact", and otherwise reads the still-populated values
   with new FormData(event.target).
3. Copy only these fields, in this order: name, email, message. Then append
   two extra values: "form" (the form name) and "page" (location.pathname).
4. Send them with fetch to SHEET_URL as a URLSearchParams body, using method
   "POST", mode "no-cors" and keepalive true. Swallow any error silently.
5. Put SHEET_URL in one clearly named variable at the top of the script so I
   can paste my Apps Script /exec URL into it.

Do not add any library, do not change the form markup, and do not delay or
block the normal submission.

FAQ

Will Spam Entries End Up In The Sheet Too?

Yes, and they arrive unlabelled. A submission caught by the automatic honeypot is still stored, still marked Spam on the Forms page, and sends no notification email, but the browser-side mirror knows none of that and posts the row anyway. Check the dashboard when a row looks odd.

Can I Send File Uploads To The Sheet?

No. File uploads are not supported at all, so there is nothing to forward, and only the filename is recorded on the entry. If people need to send documents, ask for a link to a file they have already shared.

What Happens If My Apps Script Breaks Or Hits A Quota?

The row is missing and nothing else changes. The submission was stored before your page tried to write it, so you still have the entry, the timestamp and the notification email. Fix the script, redeploy, and new submissions appear again. Older ones do not backfill.

Do I Have To Redeploy Every Time I Edit The Script?

Yes, and this is the most common reason a working setup goes quiet. Saving the file changes the code but not what the /exec URL serves. Go to Deploy, Manage deployments, click the pencil, set Version to New version, and Deploy. Skipping that step quietly breaks your forms to Google Sheets mirror while everything still looks fine on screen.

Try It Now

Still Here? Drop It In.

The whole pitch fits in one sentence: your HTML, on a link, in seconds.

Drag and drop your HTML file(s), folders, or ZIP file

or ·

No account needed. Your unclaimed website stays live for 30 minutes on a free subdomain. Claim it to your account to keep it permanently.

  • No Account Needed
  • Free SSL
  • 30-Minute Unclaimed Link, Claim To Keep It