miinideckmiinideck
PricingUse casesBlog
Sign in
How-to & formats

Send an HTML form straight to your own Google Sheet

A static page can post form submissions into a Sheet you already own, using a Google Apps Script web app as the endpoint. The fourth shape most 'form without a backend' guides leave out — how it works, the two settings that trip everyone up, and when a form service is still the better call.

By miinideck·September 9, 2026·10 min read

There is a well-worn list of ways to make a form work on a page with no backend: a mailto: link, a third-party form service, or a serverless function on a deploy platform. We wrote that list, and it is a good list.

It is also missing one, and a reader pointed that out — the shape where the submissions land in a Google Sheet you already own, with no form service in between and nothing metered.

That is the fourth shape. Here is how it actually works.

TL;DR
  • Your page cannot talk to Google on your behalf. A Google Apps Script deployed as a web app can — it runs as you, has a public URL, and appends a row to a spreadsheet you already own.
  • The form POSTs to that URL. The page stays a plain static file.
  • Two settings decide whether it works, and they are both on the deploy screen: who the script runs as, and who is allowed to call it. Almost every "it returns an error" story is one of those two.
  • No monthly submission cap and the data is in a spreadsheet you own. In exchange, the script is yours to maintain.
  • If you only need an email address in exchange for access, a page host's built-in email gate is less machinery than any of this.

Why isn't this in the usual list of three?

Because it sits in an odd position: it is not a service you sign up for, and it is not a server you run. It is a script inside an account you already have, wearing a URL.

The mental model that makes it click is the one that governs everything a static page can and cannot do: the question is never "does my page have a backend", it is "is the credential I would have to publish designed to be published". We went through that at length in anyone can read your page's source — which services can it still use. A form endpoint URL is on the publishable side by design; a Google API key with write access to your Drive very much is not.

An Apps Script web app is a way to end up on the right side of that line without involving a third party at all. The script holds the permission. The page holds nothing but a URL.

How does a static page write into a Sheet?

Four moving parts, and only one of them is new to you:

  1. A spreadsheet — the one you would have used anyway.
  2. A script bound to it, containing a function that runs when the URL receives a POST.
  3. A deployment of that script as a web app, which is what turns it into a URL.
  4. Your form, with its action pointed at that URL.

When someone submits, their browser posts the field values to the URL, the script wakes up, appends a row, and returns a response. Nothing runs on your machine, nothing is scheduled, and the spreadsheet updates while you are looking at it.

What do I actually put in the script?

Less than you would expect. This is the whole thing:

function doPost(e) {
  const sheet = SpreadsheetApp.openById('YOUR_SPREADSHEET_ID').getSheets()[0];
  const f = e.parameter;

  sheet.appendRow([new Date(), f.name, f.email, f.message]);

  return ContentService
    .createTextOutput(JSON.stringify({ ok: true }))
    .setMimeType(ContentService.MimeType.JSON);
}

Three details in there are worth knowing rather than copying, because they are where the near-miss versions go wrong. All three are Google's own words, read on 2026-09-10:

  • e.parameter is for form-encoded posts. Google's reference describes it as "An object of key/value pairs that correspond to the request parameters", and warns that "Only the first value is returned for parameters that have multiple values" — so a group of same-named checkboxes needs e.parameters (plural) instead. If you post JSON rather than a form, the body arrives as a string in e.postData.contents and you parse it yourself. Reading the wrong one gives you undefined per field, which lands in the sheet as a row of blanks.
  • appendRow takes an array and "appends a row to the bottom of the current data region in the sheet." It also carries a footgun that matters for public forms: "If a cell's content begins with =, it's interpreted as a formula." Someone typing =1+1 into your name field gets a formula in your spreadsheet, not text.
  • You have to return something. Google states the requirement plainly: a web app must contain a doGet or doPost function, and "the function returns an HTML service HtmlOutput object or a Content service TextOutput object." A function that returns nothing is not a valid web app.

Keep the column order in the script, not in the form. Fields get renamed and reordered in HTML far more often than a spreadsheet's columns do, and pinning the mapping in one place means a form edit cannot silently start writing emails into the "name" column.

Which deployment settings trip people up?

Two dropdowns on the deploy screen, and between them they explain most of the failures people describe.

They are labelled Execute as and Who has access, and Google's own tutorials refer to them by exactly those names.

Who the script runs as. The script needs your permission to touch your spreadsheet. Set it to run as you and every submission is written with your access, which is what you want — the visitor is not a Google user and has no business having access to the sheet.

Who is allowed to call it. This is the one that produces the mystery failure. If the deployment is restricted to signed-in users of your organisation, then a form submitted by an anonymous member of the public gets refused, and what you see in the browser is not a helpful message about permissions — it is a redirect to a Google login page, or an opaque error in the console. For a public form, this has to be open to anyone.

Test it from a browser that is not signed into your Google account. Testing while logged in as yourself is the classic version of checking the wrong thing: it passes for you and fails for every visitor.

Why does the console say something about CORS?

Because you are posting from your page's origin to script.google.com, which is a different one — and a cross-origin request is only allowed to happen on terms the receiving side sets.

The part worth understanding, because it decides how you write the form:

  • A plain form submission — a real <form> element with a standard encoding — is a kind of request browsers have always allowed to cross origins. It is how the web worked before any of this.
  • A fetch() with a JSON body is not. It triggers a preflight check first, and the endpoint has to answer that check before the browser will send the actual request.

So the reliable version of this is the boring one: a real form element, a standard encoding, and let the browser do what it has done since forever. If you want the nicer experience — no page navigation, an inline "thanks" — you can have it, but that is where the preflight enters and where an afternoon can disappear.

Why did it look like it failed but the row is there anyway?

Because the script and the response travel separate paths, and only the first one touches your spreadsheet.

A successful post to a web app does not return your JSON directly. It returns a redirect, and the JSON is waiting at the other end of it. The row is written before that redirect is issued — so by the time anything can go wrong with the second hop, the append has already happened.

That is why the confusing case exists. Anything that mishandles the redirect — a client that repeats the POST instead of following it as a GET, a strict CORS setup, a tool that reports the last status it saw — can hand you an error page while the row sits quietly in the sheet. We ran exactly into this while testing the script above: two attempts returned 405 and a full "Page not found" page, and both had already written their row.

The practical consequence is about retrying, not about debugging. If a submission appears to fail, check the sheet before you send it again. The instinct is to retry, and retrying a request that already succeeded is how a form ends up with duplicate entries that nobody can explain later.

Should I use this or a form service?

Neither one wins outright. Pick by which cost you would rather pay.

Apps Script → your SheetA form service
SetupHalf an hour, and you write a little codeMinutes, paste an endpoint
Where responses liveA spreadsheet you already ownTheir dashboard, exportable
Monthly submission capNone to outgrowFree tiers meter it
Spam handlingYours to addUsually included
NotificationsYours to addUsually included
When it breaksYou fix itThey fix it

The honest reading: a form service is the right default, and it stays the right default for most landing pages and event RSVPs. The Apps Script route earns its place in two specific situations — when the responses need to land in a spreadsheet that is already the working document for whatever you are running, and when the volume is the kind that makes a metered free tier a recurring decision rather than a one-off one.

Wherever the submissions end up, the page still has to be somewhere a stranger can open it — and testing the form on a real link, on a real phone, catches the things a local file never will.

Put the page on a link

What if I only need the email address?

Then this is more machinery than the job needs.

If the whole point is give me your address and I will give you the page — a resource, a template, a download — the page host's own email gate does that without a script, an endpoint or a third party: the page opens behind a short form, and the addresses collect somewhere you can export them as a file. The free tier holds 15 of them, with up to four extra fields of your own next to the address. Hosting a lead magnet that doesn't die walks that setup end to end.

Reach for the Sheet when the submission is genuinely structured data you are going to sort, filter and work through — a booking list, an equipment request, a multi-field intake form. Reach for the gate when the address is the submission.

Where should the page itself live?

Anywhere that serves a static file. That includes any of the public hosts, and it includes a private link when the form is not meant for the open web — an internal request form, a client intake page, an RSVP for a named list.

One caveat worth carrying: if the page is private and the form is public, they are two separate decisions and both are yours. The link being unguessable does not stop anyone who has it from submitting, and it does not stop them from reading the endpoint out of the source and posting to it directly. That is not a flaw in the setup; it is what a publishable endpoint is. Design the script so a junk row is an annoyance rather than a problem, and you never have to think about it again.

More in How-to & formats

Anyone can read your page's source. Which services can it still use?

A static page has no backend and no secrets, which sounds like it rules out forms, payments and bookings. It doesn't. The line isn't 'frontend versus backend' — it's whether the credential you'd have to publish is designed to be published. How to tell, in one question, before you build.

September 6, 2026·7 min read

How do I edit a password-protected HTML page without re-encrypting it?

Client-side encryption tools turn your page into an encrypted file, so the protection and the artifact are the same object — every edit means re-running the tool and re-uploading. What that actually costs, the salt setting that decides whether your old share links survive, and when a hosted password is the better trade.

September 25, 2026·6 min read

How do I hand off client work that lives in a git repo?

Running the studio out of one folder per client is a good structure for making the work. It stops at the point where the client has to open it — a private repo needs a GitHub account, and Pages built from one is public by default. What the handover step actually needs, and how to add it without breaking the folder.

September 24, 2026·8 min read

Send your own private link.

miinideck turns a single HTML file into an unguessable link with optional password and expiry. Default-private, never indexed.

Try it free →See pricing
miinideck

HTML files, finally as links — for AI builders, agencies, and consultants. Default-noindex, default-private, default-yours.

Product

  • Pricing
  • Use cases
  • Try it free

Resources

  • Blog
  • Free tools
  • Featured on
  • Report abuse

Legal

  • Privacy
  • Terms
Listed onmiinideck listed on Product Huntmiinideck listed on Faziermiinideck listed on TheSaaSDirmiinideck listed on AIToolHuntmiinideck listed on LaunchNest
© 2026 miinideckMade for people who don't want their work indexed.