TL;DR: With Google Apps Script, you can turn any Google Spreadsheet into a free form backend – no server, no third-party subscriptions, and full TypeScript type safety. The only catch: CORS. But we've got solutions for that too.
You need a contact form for your site and your first thought is Formspree, Typeform, or spinning up a backend service? Understandable – but have you considered just using a Google Spreadsheet as your backend? Sounds a bit wild, but it works surprisingly well. In this article, I'll show you how to set it all up.
🤔 Why a Spreadsheet of All Things?
Let's be honest: for most contact forms, waitlists, or feedback collectors, you don't need a full backend. A spreadsheet brings everything you need:
- Free – no hosting, no subscriptions, nada
- Instantly shareable – colleagues can view submissions right in the Sheet
- Sorting, filtering, charts – all built in
- No database setup – the spreadsheet is your database
✏️ Step 1: Prepare the Spreadsheet
Create a new Google Spreadsheet and define headers in the first row that match your form fields:
| A | B | C | D |
|---|---|---|---|
| Timestamp | Name | Message |
Important: Remember the Sheet name (default is "Sheet1") – you'll need it in the script.
🚀 Step 2: Set Up the Apps Script
Now for the fun part. Open your spreadsheet, go to Extensions → Apps Script, and replace the default code with this:
const SHEET_NAME = "Sheet1";
function doPost(e) {
const lock = LockService.getScriptLock();
lock.tryLock(10000);
try {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const data = JSON.parse(e.postData.contents);
const timestamp = new Date().toISOString();
sheet.appendRow([timestamp, data.name, data.email, data.message]);
return ContentService
.createTextOutput(JSON.stringify({ result: "success", row: sheet.getLastRow() }))
.setMimeType(ContentService.MimeType.JSON);
} catch (error) {
return ContentService
.createTextOutput(JSON.stringify({ result: "error", message: error.toString() }))
.setMimeType(ContentService.MimeType.JSON);
} finally {
lock.releaseLock();
}
}
// Optional: handle GET requests for testing
function doGet() {
return ContentService
.createTextOutput(JSON.stringify({ status: "ok", message: "Form endpoint is running" }))
.setMimeType(ContentService.MimeType.JSON);
}Pretty straightforward: doPost(e) takes the JSON body, parses it, and appends a new row to the sheet. The LockService ensures no data gets lost when multiple requests come in simultaneously.
⚙️ Step 3: Deploy as a Web App
- Click Deploy → New deployment
- Type: Web app
- Execute as: Me
- Who has access: Anyone
- Deploy → Authorize the app → Copy the URL
The URL looks like: https://script.google.com/macros/s/AKfycb.../exec
Heads up: Every time you change the script, you need to create a new deployment or update the existing one. Otherwise, your requests will keep hitting the old version.
💻 Step 4: The TypeScript Form Handler
Now for the frontend part. Here's a clean, type-safe handler you can drop into any framework or vanilla JS/TS:
interface FormData {
name: string;
email: string;
message: string;
}
interface AppsScriptResponse {
result: "success" | "error";
row?: number;
message?: string;
}
const APPS_SCRIPT_URL = "https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec";
async function submitToSheet(data: FormData): Promise<AppsScriptResponse> {
const response = await fetch(APPS_SCRIPT_URL, {
method: "POST",
mode: "no-cors",
headers: {
"Content-Type": "text/plain",
},
body: JSON.stringify(data),
});
return { result: "success" };
}
const form = document.querySelector<HTMLFormElement>("#contact-form");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
const formData: FormData = {
name: (document.getElementById("name") as HTMLInputElement).value,
email: (document.getElementById("email") as HTMLInputElement).value,
message: (document.getElementById("message") as HTMLTextAreaElement).value,
};
try {
await submitToSheet(formData);
form.reset();
alert("Submitted successfully!");
} catch (error) {
console.error("Submission failed:", error);
alert("Something went wrong. Please try again.");
}
});You probably noticed: mode: "no-cors" and Content-Type: "text/plain". Why? Let's get into that.
⚠️ The CORS Problem – and Three Ways Around It
This is where most people hit a wall: Google Apps Script can't handle CORS preflight requests (OPTIONS). In practice, this means:
Content-Type: "application/json"triggers a preflight → request failsmode: "no-cors"works, but you can't read the response body
Depending on your use case, you have three options:
Option A: no-cors (the pragmatic way)
Send with text/plain and no-cors. The data arrives, you just don't get a response back. For a simple contact form, that's perfectly fine.
Option B: Redirect Workaround
If you need the response: submit via a hidden <iframe> or use redirect: "follow". A bit more effort, but doable.
Option C: GET instead of POST (my favorite for simple forms)
Refactor the Apps Script to handle GET with query parameters. No CORS issues, and you get the response:
function doGet(e) {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const params = e.parameter;
sheet.appendRow([
new Date().toISOString(),
params.name,
params.email,
params.message,
]);
return ContentService
.createTextOutput(JSON.stringify({ result: "success" }))
.setMimeType(ContentService.MimeType.JSON);
}And on the frontend, it becomes dead simple:
async function submitViaGet(data: FormData): Promise<AppsScriptResponse> {
const params = new URLSearchParams(data as Record<string, string>);
const response = await fetch(`${APPS_SCRIPT_URL}?${params}`);
return response.json();
}🎨 Step 5: The HTML Form
The form itself is intentionally kept simple – style it however you like:
<form id="contact-form">
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit">Send</button>
</form>🔧 Bonus: Server-Side Validation
Sure, client-side validation is nice – but you don't want to rely on it. Here's an extended doPost with validation:
function doPost(e) {
const lock = LockService.getScriptLock();
lock.tryLock(10000);
try {
const data = JSON.parse(e.postData.contents);
if (!data.email || !data.email.includes("@")) {
return ContentService
.createTextOutput(JSON.stringify({ result: "error", message: "Invalid email" }))
.setMimeType(ContentService.MimeType.JSON);
}
if (!data.name || data.name.trim().length < 2) {
return ContentService
.createTextOutput(JSON.stringify({ result: "error", message: "Name is required" }))
.setMimeType(ContentService.MimeType.JSON);
}
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
sheet.appendRow([new Date().toISOString(), data.name.trim(), data.email.trim(), data.message]);
return ContentService
.createTextOutput(JSON.stringify({ result: "success" }))
.setMimeType(ContentService.MimeType.JSON);
} catch (error) {
return ContentService
.createTextOutput(JSON.stringify({ result: "error", message: error.toString() }))
.setMimeType(ContentService.MimeType.JSON);
} finally {
lock.releaseLock();
}
}📧 Bonus: Email Notifications for New Entries
Want to know right away when someone submits the form? Just add this line after appendRow:
MailApp.sendEmail({
to: "[email protected]",
subject: `New form submission from ${data.name}`,
htmlBody: `
<h3>New Contact Form Submission</h3>
<p><strong>Name:</strong> ${data.name}</p>
<p><strong>Email:</strong> ${data.email}</p>
<p><strong>Message:</strong> ${data.message}</p>
`,
});🛡️ Security Considerations
A few things to keep in mind:
- Formula Injection: If a value starts with
=,+,-, or@, Google Sheets might interpret it as a formula. Prefix such values with a single quote (') - Rate Limiting: Apps Script has built-in quotas (~20,000 calls/day), but for spam protection, you should still consider your own limits
- Honeypot Fields: Add a hidden field – if a bot fills it in, reject the submission
- Don't read sensitive data: The Apps Script URL is public. Use it for writing only, not for reading sensitive data
💡 Conclusion
Google Spreadsheets + Apps Script is a setup I've genuinely come to enjoy for simple forms. It's free, no server overhead, and the data lands directly in a Sheet you can share with your team.
The only stumbling block is CORS – but with the GET approach or no-cors, you'll have that sorted in no time. The entire code is framework-agnostic: React, Angular, Vue, Svelte, or plain HTML – all good.
Discover more articles
Angular input() for Route Parameters: Ditch ActivatedRoute for Good 🚀
Angular 16+ lets you bind route parameters directly via input() – no more ActivatedRoute boilerplate. Here’s how!
Custom-Formulare an Google Spreadsheets anbinden mit Apps Script 📊
Mit Google Apps Script kannst du jedes Google Spreadsheet in ein kostenloses Formular-Backend verwandeln – ohne Server, mit TypeScript und Lösungen für CORS-Probleme.
Mutation Observer: The invisible force in the background of your website 🕵️
The Mutation Observer is your invisible helper in the background, monitoring DOM changes in real time. Learn how to use it! 🕵️
Mutation Observer: Die unsichtbare Kraft im Hintergrund deiner Webseite 🕵️
Der Mutation Observer ist dein unsichtbarer Helfer im Hintergrund, der DOM-Änderungen in Echtzeit überwacht. Lerne, wie du ihn einsetzen kannst! 🕵️
Intersection Observer: A powerful tool for efficient web design 🚀
Discover how the Intersection Observer makes your websites more efficient and why it should be an indispensable tool in your arsenal! 🚀