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
.gsfile and one HTML file calledForm.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
doGetservesForm.htmlfor every visitor. The page is plain HTML — no framework — so you can paste in whatever stylesheet you maintain elsewhere.- 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 usingFormData. 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.handleSubmitre-validates the data on the server — trims strings, checks for an empty name, runs the email through a regex. Client-siderequiredis a courtesy; the server check is the real one.- 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:
| timestamp | name | |
|---|---|---|
| 2025-06-29 11:42:08 | Ada 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
- 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).
- Copy the
/execURL — that is the page your visitors load. - 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
CacheServiceand 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
Build a branded approval interface
Approve Northwind requests through a custom UI — clients click, decision is logged.
Updated Nov 8, 2025
Build an interactive quiz or assessment app
Run Northwind tests with scoring and feedback — questions in a Sheet, results in another.
Updated Nov 4, 2025
Build a multi-page web app with routing
Structure a real Northwind app across views — query-param routing, shared layout.
Updated Oct 31, 2025
Build a form-to-PDF web service
Convert Northwind form submissions to PDFs on the fly — POST in, PDF out.
Updated Oct 27, 2025
Build an expiring secure-download generator
Issue time-limited Northwind links via a web app — token in URL, server-side check.
Updated Oct 23, 2025