HtmlService forms avoid Google Forms when you need custom validation UX inside a web app.
submitIntake receives a payload object from the browser, validates name/email/topic, and appends a row with a generated INT- id.
Throwing Error messages surfaces in withFailureHandler on the client — keep messages user-safe.
doGet only serves Form.html; all writes go through the server function so SpreadsheetApp runs with deployment identity.
Need this built? Hire a Google Apps Script developer →
Sheet / project setup
| Sheet / File | Column / Field | Purpose |
|---|---|---|
| Form.html | name, email, topic | Client inputs |
| Intake | A Timestamp | Server time |
| Intake | B Id | INT-######## |
| Intake | C Name | Validated name |
| Intake | D Email | Lowercased email |
| Intake | E Topic | Topic text |
| Intake | F Status | New |
What this script does
submitIntake validates and appends; returns {id, message} for the success handler to show a confirmation.
Prerequisites
Form.html calling google.script.run.submitIntake; Intake sheet with headers.
- Web app deployed
- Client success/failure handlers wired
- Intake tab writable by execution identity
Walkthrough
Open the web app, submit valid and invalid payloads, confirm errors and Intake rows.
Edge cases
Duplicate emails are allowed here — add a lookup if you need upserts.
- Trim + lowercase email before storage
- UUID slice keeps ids short but not cryptographic secrets
How to test
Submit three topics; verify ids unique and Status is New.
Hardening for production
Add LockService around appendRow under concurrent load; rate-limit by email in CacheService.
Variations
Send a confirmation MailApp email after append; or write to Data Store instead of Sheets.
Full code: submitIntake()
Create Form.html that calls submitIntake(payload). Deploy as web app and test validation paths.
/**
* HtmlService form posts to a server function that validates and appends a row.
* Client: google.script.run.withSuccessHandler(...).submitIntake(payload)
*/
function doGet() {
return HtmlService.createHtmlOutputFromFile("Form")
.setTitle("Intake form");
}
function submitIntake(payload) {
payload = payload || {};
const name = String(payload.name || "").trim();
const email = String(payload.email || "").trim().toLowerCase();
const topic = String(payload.topic || "").trim();
if (name.length < 2) throw new Error("Name is required");
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("Valid email required");
if (!topic) throw new Error("Topic is required");
const sheet = SpreadsheetApp.getActive().getSheetByName("Intake");
const id = "INT-" + Utilities.getUuid().slice(0, 8).toUpperCase();
sheet.appendRow([new Date(), id, name, email, topic, "New"]);
return { id: id, message: "Submitted" };
}- Line 6: Serves the form UI.
- Line 12: Reads fields from the client object.
- Line 21: Builds a readable intake id prefix INT-.
- Line 22: Persists the validated record.
- Line 23: Success payload for the browser handler.
Deploy this example
- 01
Open Apps Script
In the bound spreadsheet: Extensions → Apps Script. For standalone projects, create one at script.google.com and link your Sheet by ID.
- 02
Paste and save
Add a .gs file, paste the code below, rename constants at the top (sheet names, column letters, API property keys), then save.
- 03
Authorize once
Run the main function from the editor. Accept OAuth scopes when prompted — triggers cannot run until authorization succeeds once.
- 04
Add the trigger
Triggers → Add trigger → choose the handler function and event (time-driven, on edit, or on form submit). Delete test triggers before production.
Before you run: HTML form
- 1Form.html exists and calls submitIntake
- 2Intake sheet headers match columns A–F
- 3Failure handler displays e.message
- 4Test empty name and bad email
- 5Confirm execution identity can edit the spreadsheet
- 6Decide execute-as user vs developer for who owns the rows
Frequently asked questions
script.run is simpler for same-origin HtmlService UIs. Use doPost for external webhooks.
google.script.run.withFailureHandler(function(e){...}) — thrown Error messages arrive there.
Pass a base64 string or use a Drive file picker pattern; multipart browser uploads need special handling.
No. Always re-validate on the server as in submitIntake.
Require Google sign-in, add CacheService rate limits, or use reCAPTCHA with a secret checked server-side.
Return JSON-like objects; let the client update the DOM.
new Date() stored in Sheets uses the spreadsheet timezone when displayed.