You can collect email signups on a static site with no server, no database and no paid tool: a Google Form stores the answers, a normal HTML form on your page posts to it, and the responses land in a Google Sheet. This is a Google Form email list for a static site in about 30 lines of HTML. It's also fragile in specific ways, and I'd rather tell you where than have you find out after 400 signups.
What I did on 2026-10-30: read Google's help pages, wrote the form and a checker script, and ran the checker plus a local stub server. What I did not do: submit to a live Google Form. The live POST behaviour below comes from community tutorials, and I say so where it matters.
What do you need before you start?
- A Google account and a new Google Form with one short-answer question titled "Email".
- Optionally, response validation on that question (Forms offers validation rules; I did not verify the exact menu on 2026-10-30). Your custom page won't show Google's validation messages anyway, which is why the HTML uses
type="email". - The form must not require sign-in. Per Google's help page (read 2026-10-30), people must sign in to a Google Account to fill in a form where that option is on, and one community write-up says the custom-front-end trick only works with sign-in off. A public newsletter signup needs it off.
- A static page where you can paste HTML. Any host works: this is just a
formelement.
If you're still deciding where the site lives, the launch checklist for an AI side project covers domain, hosting and analytics.
How do you find the entry IDs for your Google Form?
Each question has an internal field name like entry.1234567890. Your HTML form must send those exact names.
The route Google itself documents for something close to this is the pre-filled link. Per Google's "Get a pre-filled link" help page, you open the form, use More, choose Pre-fill form, type sample values and click Get link (checked 2026-10-30). The copied URL then contains one entry.NNN=value pair per question. Those entry.NNN strings are your field names.
Steps:
- In the form editor, open More, then Get pre-filled link.
- Type
test@example.comin the Email field and click Get link. - Paste the link somewhere. Find
entry.followed by digits. That's your email field name. - Repeat for any other question (for example "Where did you find us?").
The form's ID is the long string between /forms/d/e/ and /viewform in your public form URL. The endpoint that receives the data is the same URL with viewform replaced by formResponse. That swap is the part community tutorials describe and Google's help pages don't, so it's the part to re-test whenever something stops arriving.
What does the HTML form look like?
Here's the form I wrote. The IDs are placeholders you must replace. It posts into a hidden iframe so the visitor never leaves your page, then swaps in a thank-you message when the iframe finishes loading.
<form id="signup" action="https://docs.google.com/forms/d/e/YOUR_FORM_ID/formResponse"
method="POST" target="hidden_iframe" onsubmit="submitted = true;">
<label for="email">Email</label>
<input id="email" type="email" name="entry.1111111111" required autocomplete="email">
<label for="source">Where did you find us?</label>
<input id="source" type="text" name="entry.2222222222">
<button type="submit">Subscribe</button>
</form>
<iframe name="hidden_iframe" id="hidden_iframe" style="display:none"
onload="if (submitted) { document.getElementById('signup').hidden = true;
document.getElementById('thanks').hidden = false; }"></iframe>
<p id="thanks" hidden>Thanks, you're on the list.</p>
<script>var submitted = false;</script>
Three details matter:
target="hidden_iframe"sends the response into the iframe, not your page. Without it the browser navigates to Google's confirmation page.- The
submittedflag stops the iframe's first, empty load from showing "Thanks" before anyone has signed up. - Every
inputhas alabelwith a matchingfor. Don't skip it: screen-reader users and anyone tapping a small target on a phone need it.
Because your page can't read the response from another domain, "Thanks" means "the browser sent it". It does not mean "Google accepted it". Hold that thought for the testing section.
How do you check the form before you publish it?
Typos in this form fail silently. The wrong entry number, a viewform left in the action, or an iframe name that doesn't match all produce a page that looks fine and collects nothing. So I wrote a small stdlib-only checker. Save it as check_form.py:
import re
import sys
from html.parser import HTMLParser
class FormParser(HTMLParser):
def __init__(self):
super().__init__()
self.forms, self.inputs, self.labels, self.iframes = [], [], set(), []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "form":
self.forms.append(a)
elif tag == "input":
self.inputs.append(a)
elif tag == "label" and a.get("for"):
self.labels.add(a["for"])
elif tag == "iframe":
self.iframes.append(a)
def check(path):
p = FormParser()
p.feed(open(path, encoding="utf-8").read())
problems = []
if len(p.forms) != 1:
return [f"expected 1 form, found {len(p.forms)}"]
f = p.forms[0]
action = f.get("action", "")
if not re.match(r"^https://docs\.google\.com/forms/d/e/[\w-]+/formResponse$", action):
problems.append(f"action is not a .../formResponse URL: {action!r}")
if "YOUR_FORM_ID" in action:
problems.append("action still contains YOUR_FORM_ID")
if f.get("method", "").upper() != "POST":
problems.append("method must be POST")
target = f.get("target")
if target and not any(i.get("name") == target for i in p.iframes):
problems.append(f"target={target!r} has no matching iframe name")
entries = [i for i in p.inputs if (i.get("name") or "").startswith("entry.")]
if not entries:
problems.append("no entry.NNN inputs")
for i in entries:
if not re.fullmatch(r"entry\.\d+", i["name"]):
problems.append(f"bad entry name {i['name']!r}")
if i.get("id") not in p.labels:
problems.append(f"input {i['name']} has no <label for>")
email = [i for i in entries if i.get("type") == "email"]
if not email:
problems.append("no type=email input")
elif "required" not in email[0]:
problems.append("email input is not required")
return problems
if __name__ == "__main__":
probs = check(sys.argv[1])
for x in probs:
print("PROBLEM:", x)
print("OK" if not probs else f"{len(probs)} problem(s)")
I ran it on 2026-10-30 against two files. First the form above with YOUR_FORM_ID swapped for a made-up ID-shaped string:
OK
Then a deliberately broken form (a viewform action, GET method, a target with no iframe, a plain email name, a malformed entry):
PROBLEM: action is not a .../formResponse URL: 'https://docs.google.com/forms/d/e/1FAIpQLSexample/viewform'
PROBLEM: method must be POST
PROBLEM: target='nowhere' has no matching iframe name
PROBLEM: bad entry name 'entry.12ab'
PROBLEM: no type=email input
PROBLEM: input entry.12ab has no <label for>
6 problem(s)
This checks structure only. It can't tell you whether entry.1111111111 is really your email question. That needs the next step.
How do you test that responses really arrive?
Two separate checks.
1. What the browser sends. A form with no enctype posts application/x-www-form-urlencoded. I confirmed the shape of that body by pointing the same kind of POST at a local stub server (a stand-in, not Google). Real output:
Content-Type: application/x-www-form-urlencoded
Body: entry.1111111111=ada%40example.com&entry.2222222222=newsletter+%26+more
Parsed: {'entry.1111111111': ['ada@example.com'], 'entry.2222222222': ['newsletter & more']}
Status: 200
So the field names go over the wire exactly as you typed them in name, and special characters are encoded. That's all this proves.
2. What Google does with it. Open the page, submit your own address, then open the form's Responses tab. One community write-up warns that Google can return a normal-looking response even when it quietly rejects a submission, so a success message on your page proves nothing. Do this once after every change to the form: renaming or reordering questions, adding a required question, or turning on sign-in.
A rule I'd keep: add a required question later and your old HTML starts failing without any visible error. Update the HTML first.
What can go wrong with a Google Form email list?
| Problem | Why | What to do |
|---|---|---|
| Submissions silently stop | You added a required question or changed question types, or Google changed behaviour (undocumented endpoint) | Re-run the pre-filled link step, update name attributes, test and check Responses |
| Duplicate addresses | No uniqueness check without sign-in | Remove duplicates in the Sheet with a formula or Data cleanup |
| Spam entries | The endpoint is public and your HTML is readable | Check the sheet weekly; don't build anything that mails the list automatically |
| Visitor with JavaScript off | onload thank-you won't swap in | The submit still works; the page just doesn't show "Thanks" |
| Fake or typo addresses | No confirmation email | Expect some; use double opt-in once you move to a real email tool |
| Storage or size limits | Per-form caps | Google's help page (2026-10-30) states up to 500,000 responses and 1 GB per form; far beyond a small list |
What about consent and privacy?
An email address is personal data. Tell people on the form what they'll receive and who sees their address, and don't add them to a list they didn't ask to join. India's DPDP Act and other regimes can apply to you; the DPDP checklist for AI app developers is a starting point, and a professional should confirm what applies to your business. Also remember a Form's Responses sheet is a Google file with sharing settings: don't share it with a link anyone can open.
If you analyse visits to the page too, cookieless analytics you can self-host avoids adding a cookie banner just for measurement.
When should you replace the Form?
A Google Form collects addresses. It doesn't send newsletters, add unsubscribe links, or handle bounces. When you send your first email to this list, you need a tool that does. For transactional-style sending from an app, the comparison of Resend, Brevo and SES is a useful read. Export the sheet as CSV, import it, and retire the Form. Keep the HTML structure and swap the action for your new provider's.
Until then, this setup is free, takes under an hour, and doesn't need a server you'd have to patch. Just remember what it is: an undocumented door that happens to be open. Check it monthly.
Sources: Google Docs Editors Help, pre-filled links and response settings and limits, both read 2026-10-30. The formResponse technique is from community tutorials (for example DEV Community), as summarised by a search tool, not from Google's documentation.



