appscript.dev
Automation Intermediate Sheets

Build a web form that writes to Sheets

Collect Northwind submissions without Google Forms — full control over fields and styling.

Published Jun 29, 2025

Google Forms is the lazy default for collecting structured input, and most of the time it is the right choice. But Northwind has a handful of forms that need to live on the brand site — same fonts, same colours, same layout — and embedding a Google Form means accepting its chrome and its quirks. The team end up half-styling the iframe and giving up.

This pattern serves the form as an Apps Script web app. The HTML is yours, the styling is yours, the validation is yours; only the storage is Google’s. Submissions land in a Sheet as one row each, with a timestamp, and you stay in control of every pixel.

What you’ll need

  • A Google Sheet to store submissions. The script appends a row per submit with a timestamp followed by the form fields — give it headers in row 1 that match (timestamp, name, email).
  • An Apps Script project with one .gs file and one HTML file called Form.html.
  • Editor access to the storage sheet from the account that deploys the web app.

The HTML (Form.html)

<!DOCTYPE html>
<!-- Minimal styling lives here; replace with your brand styles. The form
     itself stays the same shape — name and email fields, one submit button. -->
<form id="f">
  <input name="name" required placeholder="Name">
  <input name="email" type="email" required placeholder="Email">
  <button>Send</button>
</form>
<p id="status" aria-live="polite"></p>
<script>
  const form = document.getElementById('f');
  const status = document.getElementById('status');

  form.addEventListener('submit', (e) => {
    // Stop the browser doing its own POST — we hand off to Apps Script.
    e.preventDefault();

    // Disable the button so a double-click does not double-submit.
    const button = form.querySelector('button');
    button.disabled = true;
    status.textContent = 'Sending...';

    // FormData → plain object so the server side gets named fields.
    const data = Object.fromEntries(new FormData(form));

    google.script.run
      .withSuccessHandler(() => {
        status.textContent = 'Thanks — sent.';
        form.reset();
        button.disabled = false;
      })
      .withFailureHandler((err) => {
        status.textContent = 'Error: ' + err.message;
        button.disabled = false;
      })
      .handleSubmit(data);
  });
</script>

The Apps Script

// The sheet that stores submissions. Headers expected in row 1:
//   timestamp | name | email
const SUBMISSIONS_SHEET_ID = '1abcFormSubmissionsId';

// Simple email shape check. We do not try to validate that the address
// actually receives mail — that is a hopeless quest from the client side.
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

/**
 * Serves the form page. Anyone with the web-app URL can open it.
 */
function doGet() {
  return HtmlService.createHtmlOutputFromFile('Form')
    .setTitle('Northwind contact')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

/**
 * Receives a submission from the page and appends it to the sheet.
 * Throws on bad input so the front-end can show the user a message.
 *
 * @param {Object} data Form fields as a plain object.
 */
function handleSubmit(data) {
  // 1. Defend against empty payloads — google.script.run will happily
  //    pass through whatever the page sent, including undefined.
  const name = String((data && data.name) || '').trim();
  const email = String((data && data.email) || '').trim();

  if (!name) throw new Error('Name is required.');
  if (!EMAIL_REGEX.test(email)) throw new Error('Email looks wrong.');

  // 2. Append one row. The timestamp is server-side so a clock-skewed
  //    client cannot back-date its submission.
  SpreadsheetApp.openById(SUBMISSIONS_SHEET_ID).getSheets()[0]
    .appendRow([new Date(), name, email]);
}

How it works

  1. doGet serves Form.html for every visitor. The page is plain HTML — no framework — so you can paste in whatever stylesheet you maintain elsewhere.
  2. When the form is submitted, the page calls e.preventDefault() to suppress the browser’s own POST and bundles the field values into a plain object using FormData.
  3. google.script.run.handleSubmit(data) ships the object to the server. The submit button disables itself for the duration so a quick second click does not double-record.
  4. handleSubmit re-validates the data on the server — trims strings, checks for an empty name, runs the email through a regex. Client-side required is a courtesy; the server check is the real one.
  5. If validation passes, the script appends one row to the sheet with a server-side timestamp followed by the field values. Success and error handlers in the page update the status line and re-enable the button.

Example run

A visitor opens the web-app URL, types Ada Lovelace and [email protected], and clicks Send.

The submissions sheet gets a new row:

timestampnameemail
2025-06-29 11:42:08Ada Lovelace[email protected]

A visitor who types Ada and not-an-email triggers the failure handler — the status line reads Error: Email looks wrong. and nothing is written.

Deploy it

  1. In the Apps Script editor click Deploy → New deployment, pick Web app, set Execute as to your account and Who has access to Anyone (or Anyone in your domain for internal forms).
  2. Copy the /exec URL — that is the page your visitors load.
  3. Test from an incognito window so you submit as a real visitor would, not as the script owner.

When you change the script or Form.html, redeploy a new version: Deploy → Manage deployments → edit → New version. The URL is stable.

Watch out for

  • All writes happen as the deployer. Anyone who reaches the web app can cause a row to be appended, so do not put privileged actions behind the same handleSubmit. Keep this script to form storage and nothing else.
  • The email regex is intentionally loose — it catches typos like a missing @, not whether the address routes. If a confirmation matters, send a test mail and wait for a reply.
  • HTML templating in Apps Script does not auto-escape values. If you ever echo a submitted value back into HTML, encode it first or you have an XSS hole.
  • The sheet append runs serially; a sudden flood of submissions queues up and may hit the per-script execution rate limit. For high-traffic forms, buffer submissions through CacheService and flush in batches — see Cache API responses to stay under quotas.
  • Spammers do find unguarded endpoints. Add a honeypot field (<input name="company" style="display:none">) and reject submissions where it is non-empty; bots fill every field, humans never see it.

Related